@xingular/harness 0.0.0-stage → 0.1.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/README.md +269 -2
- package/dist/cli.js +11046 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.ts +204 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +289 -0
- package/src/index.ts +69 -0
- package/src/mcp/connections.ts +441 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +567 -0
- package/src/mcp/types.ts +105 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +287 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +128 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +250 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +44 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +184 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +130 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +308 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +394 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// The accounts service: everything that needs the secret store, the Pi runtime, or a notice.
|
|
2
|
+
// Credentials go to the embedding application's secret store; nothing here writes one to the
|
|
3
|
+
// harness's own files, except a terminal session's `auth.json` while that terminal runs.
|
|
4
|
+
import { chmodSync, rmSync } from 'node:fs'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
|
|
9
|
+
|
|
10
|
+
import { log, type Bus } from '../core/bus'
|
|
11
|
+
import { HarnessError, invalid } from '../core/errors'
|
|
12
|
+
import { atomicWrite, now, readJson, safeId } from '../core/fs'
|
|
13
|
+
import type { RunEvent, RunRecord } from '../sessions/contracts'
|
|
14
|
+
import * as env from './env'
|
|
15
|
+
import { secretKey, type SecretStore } from './secrets'
|
|
16
|
+
import * as store from './store'
|
|
17
|
+
import type { Account, AccountsOverview, Issue, Limit } from './store'
|
|
18
|
+
|
|
19
|
+
export type IssueCategory = 'auth_expired' | 'rate_limited' | 'quota' | 'network' | 'unknown'
|
|
20
|
+
|
|
21
|
+
/** Best-effort classification of a provider error message. */
|
|
22
|
+
export function classifyMessage(message: string): IssueCategory | null {
|
|
23
|
+
const text = message.toLowerCase()
|
|
24
|
+
if (/\b401\b|unauthori[sz]ed|invalid (api )?key|invalid x-api-key|authentication|not logged in|log ?in again|expired token|token (has )?expired|refresh token|oauth/.test(text)) return 'auth_expired'
|
|
25
|
+
if (/usage limit|quota|insufficient_quota|billing|credit balance|out of credits/.test(text)) return 'quota'
|
|
26
|
+
if (/\b429\b|rate.?limit|too many requests/.test(text)) return 'rate_limited'
|
|
27
|
+
if (/econnrefused|enotfound|etimedout|network|socket hang up|fetch failed/.test(text)) return 'network'
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelOption {
|
|
32
|
+
id: string
|
|
33
|
+
name: string
|
|
34
|
+
provider: string
|
|
35
|
+
supportsImages: boolean
|
|
36
|
+
reasoning: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Credential = Record<string, unknown> & { type: 'api_key' | 'oauth' }
|
|
40
|
+
|
|
41
|
+
function validProvider(provider: string): string {
|
|
42
|
+
if (provider && provider.length <= 64 && /^[A-Za-z0-9._-]+$/.test(provider)) return provider
|
|
43
|
+
throw invalid('Invalid provider name.')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PendingLogin {
|
|
47
|
+
accountId: string
|
|
48
|
+
cancel: () => void
|
|
49
|
+
prompts: Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Probe {
|
|
53
|
+
signedIn: boolean | null
|
|
54
|
+
authMethod: string | null
|
|
55
|
+
plan: string | null
|
|
56
|
+
identity: string | null
|
|
57
|
+
limits?: Limit[]
|
|
58
|
+
capabilities: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AccountsOptions {
|
|
62
|
+
/** `<state dir>/accounts`. */
|
|
63
|
+
dir: string
|
|
64
|
+
secrets: SecretStore
|
|
65
|
+
bus: Bus
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Accounts {
|
|
69
|
+
readonly dir: string
|
|
70
|
+
private readonly secrets: SecretStore
|
|
71
|
+
private readonly bus: Bus
|
|
72
|
+
private readonly runtimes = new Map<string, Promise<ModelRuntime>>()
|
|
73
|
+
private readonly logins = new Map<string, PendingLogin>()
|
|
74
|
+
private readonly terminals = new Map<string, number>()
|
|
75
|
+
|
|
76
|
+
constructor(options: AccountsOptions) {
|
|
77
|
+
this.dir = options.dir
|
|
78
|
+
this.secrets = options.secrets
|
|
79
|
+
this.bus = options.bus
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private changed(payload: Record<string, unknown> = {}): void {
|
|
83
|
+
this.bus.emit({ type: 'accounts.changed', ...payload })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
overview(): AccountsOverview {
|
|
87
|
+
return store.overview(this.dir)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
find(accountId: string): Account {
|
|
91
|
+
return store.find(this.dir, accountId)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The account a new session uses; a session that already has one passes it and keeps it. */
|
|
95
|
+
resolve(accountId?: string | null, poolId?: string | null): Account {
|
|
96
|
+
return store.resolve(this.dir, accountId, poolId)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
context(account: Account): env.AccountContext {
|
|
100
|
+
return env.accountContext(this.dir, account)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- Stored credentials -----------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async readCredential(accountId: string, provider: string): Promise<Credential | null> {
|
|
106
|
+
this.find(accountId)
|
|
107
|
+
const text = await this.secrets.get(secretKey(accountId, `pi-auth/${validProvider(provider)}`))
|
|
108
|
+
if (!text) return null
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(text) as Credential
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Stores a provider's credential, or removes it when `credential` is `null`. */
|
|
117
|
+
async writeCredential(accountId: string, provider: string, credential: Credential | null): Promise<void> {
|
|
118
|
+
this.find(accountId)
|
|
119
|
+
const name = validProvider(provider)
|
|
120
|
+
if (credential === null) await this.secrets.delete(secretKey(accountId, `pi-auth/${name}`))
|
|
121
|
+
else await this.secrets.set(secretKey(accountId, `pi-auth/${name}`), JSON.stringify(credential))
|
|
122
|
+
store.updateState(this.dir, accountId, (state) => {
|
|
123
|
+
state.piProviders = state.piProviders.filter((existing) => existing !== name)
|
|
124
|
+
if (credential !== null) state.piProviders = [...state.piProviders, name].sort()
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async listCredentials(accountId: string): Promise<{ providerId: string; type: 'api_key' | 'oauth' }[]> {
|
|
129
|
+
this.find(accountId)
|
|
130
|
+
const listed: { providerId: string; type: 'api_key' | 'oauth' }[] = []
|
|
131
|
+
for (const provider of store.loadState(this.dir, accountId).piProviders) {
|
|
132
|
+
const credential = await this.readCredential(accountId, provider).catch(() => null)
|
|
133
|
+
if (credential && typeof credential.type === 'string') listed.push({ providerId: provider, type: credential.type })
|
|
134
|
+
}
|
|
135
|
+
return listed
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pi's `CredentialStore` for one account. Writes are serialized, so a refresh never races another. */
|
|
139
|
+
private credentialStore(accountId: string) {
|
|
140
|
+
let chain: Promise<unknown> = Promise.resolve()
|
|
141
|
+
const serialize = <T>(work: () => Promise<T>): Promise<T> => {
|
|
142
|
+
const next = chain.then(work)
|
|
143
|
+
chain = next.catch(() => undefined)
|
|
144
|
+
return next
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
read: async (providerId: string) => (await this.readCredential(accountId, providerId)) ?? undefined,
|
|
148
|
+
list: () => this.listCredentials(accountId),
|
|
149
|
+
modify: (providerId: string, change: (current: Credential | undefined) => Promise<Credential | undefined>) =>
|
|
150
|
+
serialize(async () => {
|
|
151
|
+
const current = (await this.readCredential(accountId, providerId)) ?? undefined
|
|
152
|
+
const updated = await change(current)
|
|
153
|
+
if (updated !== undefined) await this.writeCredential(accountId, providerId, updated)
|
|
154
|
+
return updated ?? current
|
|
155
|
+
}),
|
|
156
|
+
delete: (providerId: string) => serialize(() => this.writeCredential(accountId, providerId, null))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- The Pi runtime ---------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/** The lazily loaded Pi runtime of one account: its models and its credentials. Pi's own `~/.pi` is never used. */
|
|
163
|
+
runtime(account: env.AccountContext | Account): Promise<ModelRuntime> {
|
|
164
|
+
const id = account.id
|
|
165
|
+
let runtime = this.runtimes.get(id)
|
|
166
|
+
if (!runtime) {
|
|
167
|
+
const configDir = 'configDir' in account ? account.configDir : env.ensureHome(this.dir, account)
|
|
168
|
+
runtime = import('@earendil-works/pi-coding-agent')
|
|
169
|
+
.then(({ ModelRuntime }) => ModelRuntime.create({ credentials: this.credentialStore(id) as never, modelsPath: join(configDir, 'models.json') }))
|
|
170
|
+
.catch((error: unknown) => {
|
|
171
|
+
this.runtimes.delete(id)
|
|
172
|
+
throw error
|
|
173
|
+
})
|
|
174
|
+
this.runtimes.set(id, runtime)
|
|
175
|
+
}
|
|
176
|
+
return runtime
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Drops a cached runtime after its credentials changed outside it (sign-in, key edits, terminal sessions). */
|
|
180
|
+
invalidate(accountId: string): void {
|
|
181
|
+
this.runtimes.delete(accountId)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async models(accountId?: string | null): Promise<ModelOption[]> {
|
|
185
|
+
const account = accountId ? this.find(accountId) : store.defaultAccount(this.dir)
|
|
186
|
+
if (!account) return []
|
|
187
|
+
const available = await (await this.runtime(account)).getAvailable()
|
|
188
|
+
return available.map((model) => ({ id: `${model.provider}/${model.id}`, name: model.name || model.id, provider: String(model.provider), supportsImages: model.input.includes('image'), reasoning: model.reasoning }))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether sessions can run, and why not. */
|
|
192
|
+
async status(accountId?: string | null): Promise<{ available: boolean; reason: string | null; models: number }> {
|
|
193
|
+
const account = accountId ? this.find(accountId) : store.defaultAccount(this.dir)
|
|
194
|
+
if (!account) return { available: false, reason: 'Add a Pi account in Accounts to use Chat.', models: 0 }
|
|
195
|
+
try {
|
|
196
|
+
const models = await this.models(account.id)
|
|
197
|
+
return models.length ? { available: true, reason: null, models: models.length } : { available: false, reason: `The Pi account "${account.label}" has no signed-in provider. Sign in or add a key in Accounts.`, models: 0 }
|
|
198
|
+
} catch (error) {
|
|
199
|
+
log('Pi runtime failed to load', error instanceof Error ? error.message : String(error))
|
|
200
|
+
return { available: false, reason: 'The Pi SDK could not be loaded.', models: 0 }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Providers Pi can sign in to (OAuth) or take a key for, independent of any account. */
|
|
205
|
+
async providers(): Promise<{ id: string; name: string; oauth: boolean; oauthLabel: string | null; subscription: boolean; apiKey: boolean }[]> {
|
|
206
|
+
const { ModelRuntime } = await import('@earendil-works/pi-coding-agent')
|
|
207
|
+
const none = { read: async () => undefined, list: async () => [], modify: async () => undefined, delete: async () => undefined }
|
|
208
|
+
const runtime = await ModelRuntime.create({ credentials: none, modelsPath: null, refreshOnCreate: false })
|
|
209
|
+
return runtime
|
|
210
|
+
.getProviders()
|
|
211
|
+
.map((provider) => ({ id: provider.id, name: provider.name, oauth: Boolean(provider.auth.oauth), oauthLabel: provider.auth.oauth?.loginLabel ?? provider.auth.oauth?.name ?? null, subscription: Boolean(provider.auth.oauth?.isSubscription), apiKey: Boolean(provider.auth.apiKey?.login) }))
|
|
212
|
+
.filter((provider) => provider.oauth || provider.apiKey)
|
|
213
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- Status checks ----------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
private async probe(account: Account): Promise<Probe> {
|
|
219
|
+
const runtime = await this.runtime(account)
|
|
220
|
+
const credentials = await runtime.listCredentials()
|
|
221
|
+
const available = await runtime.getAvailable()
|
|
222
|
+
const subscription = credentials.filter((credential) => runtime.isUsingSubscription(credential.providerId)).map((credential) => credential.providerId)
|
|
223
|
+
return {
|
|
224
|
+
signedIn: credentials.length > 0 && available.length > 0,
|
|
225
|
+
authMethod: [...new Set(credentials.map((credential) => credential.type))].join(', ') || null,
|
|
226
|
+
plan: subscription.length ? `Subscription: ${subscription.join(', ')}` : null,
|
|
227
|
+
identity: credentials.map((credential) => credential.providerId).join(', ') || null,
|
|
228
|
+
capabilities: [`models:${available.length}`, ...credentials.map((credential) => `provider:${credential.providerId}`)]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Checks one account and records what Pi reports. */
|
|
233
|
+
async refreshOne(account: Account): Promise<void> {
|
|
234
|
+
let probe: Probe | null = null
|
|
235
|
+
let failure: string | null = null
|
|
236
|
+
try {
|
|
237
|
+
probe = await this.probe(account)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
failure = error instanceof Error ? error.message : String(error)
|
|
240
|
+
}
|
|
241
|
+
const { previous, state } = store.updateState(this.dir, account.id, (current) => {
|
|
242
|
+
current.lastCheckedAt = now()
|
|
243
|
+
if (probe) {
|
|
244
|
+
current.signedIn = probe.signedIn
|
|
245
|
+
current.authMethod = probe.authMethod
|
|
246
|
+
current.plan = probe.plan ?? current.plan
|
|
247
|
+
current.identity = probe.identity
|
|
248
|
+
if (probe.limits) current.limits = probe.limits
|
|
249
|
+
current.capabilities = probe.capabilities
|
|
250
|
+
const keepLimitError = current.lastError !== null && store.isLimitCategory(current.lastError.category) && store.limitErrorActive(current.lastError)
|
|
251
|
+
if (probe.signedIn === true && !keepLimitError) current.lastError = null
|
|
252
|
+
} else {
|
|
253
|
+
current.lastError = { category: 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
if (previous !== state.health) store.audit(this.dir, account.id, 'health.changed', `${previous} → ${state.health} (status check)`)
|
|
257
|
+
this.changed({ accountId: account.id })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async refresh(accountId?: string | null): Promise<AccountsOverview> {
|
|
261
|
+
const accounts = accountId ? [this.find(accountId)] : store.loadRegistry(this.dir).accounts.filter((account) => account.provider === store.PROVIDER)
|
|
262
|
+
for (const account of accounts) await this.refreshOne(account)
|
|
263
|
+
return this.overview()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- Create, rename, remove -------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
create(label: string, authKind: string): AccountsOverview {
|
|
269
|
+
const account = store.addAccount(this.dir, label, authKind)
|
|
270
|
+
env.ensureHome(this.dir, account)
|
|
271
|
+
store.audit(this.dir, account.id, 'account.created', `Pi account "${account.label}" (${account.authKind})`)
|
|
272
|
+
this.changed({ accountId: account.id })
|
|
273
|
+
return this.overview()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
rename(accountId: string, label: string): AccountsOverview {
|
|
277
|
+
store.renameAccount(this.dir, accountId, label)
|
|
278
|
+
store.audit(this.dir, accountId, 'account.renamed', store.cleanLabel(label))
|
|
279
|
+
this.changed({ accountId })
|
|
280
|
+
return this.overview()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
setDefault(accountId: string): AccountsOverview {
|
|
284
|
+
store.setDefault(this.dir, accountId)
|
|
285
|
+
store.audit(this.dir, accountId, 'default.changed', 'Default Pi account for new sessions.')
|
|
286
|
+
this.changed({ accountId })
|
|
287
|
+
return this.overview()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async forgetCredentials(account: Account): Promise<void> {
|
|
291
|
+
for (const provider of store.loadState(this.dir, account.id).piProviders) await this.writeCredential(account.id, provider, null)
|
|
292
|
+
this.invalidate(account.id)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Deletes the account: removes its stored credentials and its runtime folder. Sessions pinned to it ask for another account next time they start. */
|
|
296
|
+
async remove(accountId: string): Promise<AccountsOverview> {
|
|
297
|
+
const account = this.find(accountId)
|
|
298
|
+
await this.forgetCredentials(account).catch(() => undefined)
|
|
299
|
+
await this.secrets.delete(secretKey(account.id, 'apiKey')).catch(() => undefined)
|
|
300
|
+
store.forgetAccount(this.dir, account)
|
|
301
|
+
rmSync(join(this.dir, safeId(account.id)), { recursive: true, force: true })
|
|
302
|
+
store.audit(this.dir, account.id, 'account.deleted', `"${account.label}" and its stored sign-in were removed.`)
|
|
303
|
+
this.changed({ accountId: account.id })
|
|
304
|
+
return this.overview()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
savePool(pool: { id?: string; label: string; accountIds: string[] }): AccountsOverview {
|
|
308
|
+
const saved = store.savePool(this.dir, pool)
|
|
309
|
+
store.audit(this.dir, null, 'pool.saved', `${saved.label}: ${saved.accountIds.length} account(s)`)
|
|
310
|
+
this.changed()
|
|
311
|
+
return this.overview()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
removePool(poolId: string): AccountsOverview {
|
|
315
|
+
store.removePool(this.dir, poolId)
|
|
316
|
+
store.audit(this.dir, null, 'pool.removed', poolId)
|
|
317
|
+
this.changed()
|
|
318
|
+
return this.overview()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- Keys and sign-in -------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
async setApiKey(accountId: string, rawKey: string, provider: string): Promise<AccountsOverview> {
|
|
324
|
+
const account = this.find(accountId)
|
|
325
|
+
const key = rawKey.trim()
|
|
326
|
+
if (!key || key.length > 4096 || /\s/.test(key)) throw invalid('Paste the key without spaces.')
|
|
327
|
+
if (!provider) throw invalid('Choose which Pi provider the key is for.')
|
|
328
|
+
await this.writeCredential(account.id, provider, { type: 'api_key', key })
|
|
329
|
+
this.invalidate(account.id)
|
|
330
|
+
store.audit(this.dir, account.id, 'key.set', 'Provider key stored in the Keychain.')
|
|
331
|
+
await this.refreshOne(account)
|
|
332
|
+
return this.overview()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async removeProvider(accountId: string, provider: string): Promise<AccountsOverview> {
|
|
336
|
+
const account = this.find(accountId)
|
|
337
|
+
await this.writeCredential(account.id, provider, null)
|
|
338
|
+
this.invalidate(account.id)
|
|
339
|
+
store.audit(this.dir, account.id, 'key.removed', provider)
|
|
340
|
+
await this.refreshOne(account)
|
|
341
|
+
return this.overview()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async signOut(accountId: string): Promise<AccountsOverview> {
|
|
345
|
+
const account = this.find(accountId)
|
|
346
|
+
await this.forgetCredentials(account)
|
|
347
|
+
store.audit(this.dir, account.id, 'logout', 'Signed out; stored credentials removed.')
|
|
348
|
+
store.updateState(this.dir, account.id, (current) => {
|
|
349
|
+
current.signedIn = false
|
|
350
|
+
current.identity = null
|
|
351
|
+
current.lastError = null
|
|
352
|
+
})
|
|
353
|
+
this.changed({ accountId: account.id })
|
|
354
|
+
return this.overview()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private finishLogin(loginId: string, success: boolean, error: string | null): void {
|
|
358
|
+
const login = this.logins.get(loginId)
|
|
359
|
+
if (!login) return
|
|
360
|
+
this.logins.delete(loginId)
|
|
361
|
+
for (const prompt of login.prompts.values()) prompt.reject(new Error('Sign-in ended.'))
|
|
362
|
+
if (success) this.invalidate(login.accountId)
|
|
363
|
+
store.audit(this.dir, login.accountId, success ? 'login.completed' : 'login.failed', error ?? '')
|
|
364
|
+
// A fresh sign-in starts clean; a limit that still applies is reported again by the next run.
|
|
365
|
+
if (success) store.updateState(this.dir, login.accountId, (current) => void (current.lastError = null))
|
|
366
|
+
this.bus.emit({ type: 'accounts.login', accountId: login.accountId, loginId, success, error })
|
|
367
|
+
try {
|
|
368
|
+
void this.refreshOne(this.find(login.accountId)).catch(() => undefined)
|
|
369
|
+
} catch {
|
|
370
|
+
// The account was removed while its sign-in was running.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Starts a provider's own sign-in for this account. Progress, links to open, and prompts
|
|
376
|
+
* arrive as `accounts.loginEvent` notices; the outcome as `accounts.login`.
|
|
377
|
+
*/
|
|
378
|
+
async loginStart(accountId: string, provider: string, method?: string | null): Promise<{ loginId: string }> {
|
|
379
|
+
const account = this.find(accountId)
|
|
380
|
+
if (!provider) throw invalid('Choose which Pi provider to sign in to.')
|
|
381
|
+
store.audit(this.dir, account.id, 'login.started', `${method ?? 'oauth'} · ${provider}`)
|
|
382
|
+
const runtime = await this.runtime(account)
|
|
383
|
+
const loginId = randomUUID()
|
|
384
|
+
const controller = new AbortController()
|
|
385
|
+
const login: PendingLogin = { accountId: account.id, prompts: new Map(), cancel: () => controller.abort() }
|
|
386
|
+
this.logins.set(loginId, login)
|
|
387
|
+
const event = (payload: Record<string, unknown>): void => this.bus.emit({ type: 'accounts.loginEvent', accountId: account.id, loginId, event: payload })
|
|
388
|
+
const interaction = {
|
|
389
|
+
signal: controller.signal,
|
|
390
|
+
notify: (notification: Record<string, unknown>) => event(notification),
|
|
391
|
+
prompt: (prompt: { type: string; message: string; placeholder?: string; options?: unknown; signal?: AbortSignal }) =>
|
|
392
|
+
new Promise<string>((resolve, reject) => {
|
|
393
|
+
const promptId = randomUUID()
|
|
394
|
+
login.prompts.set(promptId, { resolve, reject })
|
|
395
|
+
prompt.signal?.addEventListener('abort', () => {
|
|
396
|
+
login.prompts.delete(promptId)
|
|
397
|
+
event({ type: 'promptClosed', promptId })
|
|
398
|
+
reject(new Error('Prompt closed.'))
|
|
399
|
+
})
|
|
400
|
+
event({ type: 'prompt', promptId, prompt: { type: prompt.type, message: prompt.message, placeholder: prompt.placeholder ?? null, options: prompt.options ?? null } })
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
runtime
|
|
404
|
+
.login(provider, method === 'api_key' ? 'api_key' : 'oauth', interaction as never)
|
|
405
|
+
.then(() => this.finishLogin(loginId, true, null))
|
|
406
|
+
.catch((error: unknown) => this.finishLogin(loginId, false, controller.signal.aborted ? 'Sign-in was cancelled.' : error instanceof Error ? error.message : String(error)))
|
|
407
|
+
return { loginId }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
loginRespond(loginId: string, promptId: string, value: string | null): void {
|
|
411
|
+
const login = this.logins.get(loginId)
|
|
412
|
+
const prompt = login?.prompts.get(promptId)
|
|
413
|
+
if (!login || !prompt) throw new HarnessError('not_found', 'That sign-in step is no longer waiting.')
|
|
414
|
+
login.prompts.delete(promptId)
|
|
415
|
+
if (value === null) prompt.reject(new Error('Cancelled.'))
|
|
416
|
+
else prompt.resolve(value)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
loginCancel(accountId: string, loginId?: string | null): void {
|
|
420
|
+
if (loginId) this.logins.get(loginId)?.cancel()
|
|
421
|
+
store.audit(this.dir, accountId, 'login.cancelled', '')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- Terminal sessions --------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The `pi` command reads `auth.json` from its agent folder. For a terminal session the
|
|
428
|
+
* account's stored credentials are written there (mode 0600) while the terminal runs, then
|
|
429
|
+
* read back and removed.
|
|
430
|
+
*/
|
|
431
|
+
async terminalBegin(account: Account): Promise<void> {
|
|
432
|
+
const count = (this.terminals.get(account.id) ?? 0) + 1
|
|
433
|
+
this.terminals.set(account.id, count)
|
|
434
|
+
if (count > 1) return
|
|
435
|
+
const path = join(env.configDir(this.dir, account), 'auth.json')
|
|
436
|
+
try {
|
|
437
|
+
const data: Record<string, unknown> = {}
|
|
438
|
+
for (const provider of store.loadState(this.dir, account.id).piProviders) {
|
|
439
|
+
const credential = await this.readCredential(account.id, provider).catch(() => null)
|
|
440
|
+
if (credential) data[provider] = credential
|
|
441
|
+
}
|
|
442
|
+
atomicWrite(path, JSON.stringify(data, null, 2))
|
|
443
|
+
chmodSync(path, 0o600)
|
|
444
|
+
} catch (error) {
|
|
445
|
+
this.terminals.delete(account.id)
|
|
446
|
+
rmSync(path, { force: true })
|
|
447
|
+
throw error
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async terminalEnd(account: Account): Promise<void> {
|
|
452
|
+
const remaining = Math.max(0, (this.terminals.get(account.id) ?? 1) - 1)
|
|
453
|
+
if (remaining > 0) {
|
|
454
|
+
this.terminals.set(account.id, remaining)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
this.terminals.delete(account.id)
|
|
458
|
+
const path = join(env.configDir(this.dir, account), 'auth.json')
|
|
459
|
+
let data: Record<string, Credential> | null = null
|
|
460
|
+
try {
|
|
461
|
+
const parsed = readJson<Record<string, Credential>>(path)
|
|
462
|
+
data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
463
|
+
} catch {
|
|
464
|
+
data = null
|
|
465
|
+
}
|
|
466
|
+
if (data) {
|
|
467
|
+
for (const provider of store.loadState(this.dir, account.id).piProviders) if (!(provider in data)) await this.writeCredential(account.id, provider, null).catch(() => undefined)
|
|
468
|
+
for (const [provider, credential] of Object.entries(data)) await this.writeCredential(account.id, provider, credential).catch(() => undefined)
|
|
469
|
+
this.invalidate(account.id)
|
|
470
|
+
store.audit(this.dir, account.id, 'credentials.synced', 'Pi terminal sign-in saved to the Keychain.')
|
|
471
|
+
}
|
|
472
|
+
rmSync(path, { force: true })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---- Health from runs ---------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/** Tracks account health and limits from run events. `run` is the event's run record, when it is known. */
|
|
478
|
+
onRunEvent(event: RunEvent, run: RunRecord | null): void {
|
|
479
|
+
if (event.type === 'account.status') this.applyStatus(event)
|
|
480
|
+
else if (event.type === 'completed' && run?.accountId) {
|
|
481
|
+
const accountId = run.accountId
|
|
482
|
+
const { previous, state } = store.updateState(this.dir, accountId, (current) => {
|
|
483
|
+
// A finished run proves the sign-in works; limit windows and limit errors stay until they reset.
|
|
484
|
+
if (current.lastError && (!store.isLimitCategory(current.lastError.category) || !store.limitErrorActive(current.lastError))) current.lastError = null
|
|
485
|
+
current.signedIn = true
|
|
486
|
+
})
|
|
487
|
+
if (previous !== state.health) this.changed({ accountId, health: state.health, previous })
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private applyStatus(event: RunEvent): void {
|
|
492
|
+
const payload = event.payload as { accountId?: unknown; category?: unknown; message?: unknown; limits?: unknown; plan?: unknown; resetsAt?: unknown }
|
|
493
|
+
if (typeof payload.accountId !== 'string') return
|
|
494
|
+
const accountId = payload.accountId
|
|
495
|
+
const text = (value: unknown): string | null => (typeof value === 'string' ? value : null)
|
|
496
|
+
const category = text(payload.category)
|
|
497
|
+
const message = text(payload.message) === null ? null : store.scrub(payload.message as string)
|
|
498
|
+
const limits = Array.isArray(payload.limits) ? (payload.limits as Limit[]) : null
|
|
499
|
+
let result: ReturnType<typeof store.updateState>
|
|
500
|
+
try {
|
|
501
|
+
result = store.updateState(this.dir, accountId, (state) => {
|
|
502
|
+
if (limits) {
|
|
503
|
+
for (const limit of limits) state.limits = [...state.limits.filter((existing) => existing.id !== limit.id), limit]
|
|
504
|
+
state.limits.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
|
|
505
|
+
}
|
|
506
|
+
if (text(payload.plan) !== null) state.plan = text(payload.plan)
|
|
507
|
+
if (category) {
|
|
508
|
+
state.lastError = { category, message: message ?? '', at: now(), resetsAt: text(payload.resetsAt) } satisfies Issue
|
|
509
|
+
if (category === 'auth_expired') state.signedIn = false
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
} catch {
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
if (result.previous !== result.state.health) {
|
|
516
|
+
store.audit(this.dir, accountId, 'health.changed', `${result.previous} → ${result.state.health}${message ? `: ${message}` : ''}`)
|
|
517
|
+
this.changed({ accountId, health: result.state.health, previous: result.previous, message, resetsAt: text(payload.resetsAt) })
|
|
518
|
+
} else {
|
|
519
|
+
this.changed({ accountId })
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|