@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,204 @@
|
|
|
1
|
+
// Isolated launch environments. Every Pi session the harness starts gets its account's own
|
|
2
|
+
// agent folder (`PI_CODING_AGENT_DIR`) under the state directory, and inherited credential
|
|
3
|
+
// variables are removed. A launch that would fall back to `~/.pi` is refused.
|
|
4
|
+
//
|
|
5
|
+
// Settings, skills, prompts, and instructions are shared by every account: the account's agent
|
|
6
|
+
// folder links those entries to `_shared/pi/`. Credentials, sessions, and history stay in the
|
|
7
|
+
// account's own folder.
|
|
8
|
+
import { chmodSync, lstatSync, mkdirSync, readlinkSync, renameSync, symlinkSync, unlinkSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { basename, dirname, join, sep } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { HarnessError } from '../core/errors'
|
|
13
|
+
import type { Account } from './store'
|
|
14
|
+
|
|
15
|
+
// Config-directory variables the harness always sets itself, including those of other tools so
|
|
16
|
+
// a session can never inherit them.
|
|
17
|
+
const DIR_VARS = ['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR']
|
|
18
|
+
|
|
19
|
+
/** Inherited variables that carry credentials; they would bypass the account's own sign-in. */
|
|
20
|
+
export function isCredentialVar(name: string): boolean {
|
|
21
|
+
const upper = name.toUpperCase()
|
|
22
|
+
return DIR_VARS.includes(upper) || ['_API_KEY', '_AUTH_TOKEN', '_OAUTH_TOKEN', '_ACCESS_TOKEN'].some((suffix) => upper.endsWith(suffix))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Names of inherited variables to remove from a session's process. */
|
|
26
|
+
export function inheritedCredentialVars(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
27
|
+
const names = Object.keys(env).filter(isCredentialVar)
|
|
28
|
+
for (const name of DIR_VARS) if (!names.includes(name)) names.push(name)
|
|
29
|
+
return names
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Makes this process safe to run Pi sessions in: credentials come only from an account's own
|
|
34
|
+
* store, so inherited credential variables are dropped, and Pi's fallback agent folder is an
|
|
35
|
+
* empty one under the state directory rather than `~/.pi`.
|
|
36
|
+
*/
|
|
37
|
+
export function isolateProcess(accountsDir: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
38
|
+
for (const name of inheritedCredentialVars(env)) delete env[name]
|
|
39
|
+
const fallback = join(accountsDir, '.sidecar', 'pi')
|
|
40
|
+
mkdirSync(fallback, { recursive: true })
|
|
41
|
+
env.PI_CODING_AGENT_DIR = fallback
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const homeDir = (accountsDir: string, accountId: string): string => join(accountsDir, accountId, 'home')
|
|
45
|
+
/** Pi's agent folder for this account. */
|
|
46
|
+
export const configDir = (accountsDir: string, account: Pick<Account, 'id'>): string => join(homeDir(accountsDir, account.id), 'pi')
|
|
47
|
+
/** The agent-folder entries every account shares. */
|
|
48
|
+
export const sharedDir = (accountsDir: string): string => join(accountsDir, '_shared', 'pi')
|
|
49
|
+
|
|
50
|
+
function makePrivate(path: string): void {
|
|
51
|
+
mkdirSync(path, { recursive: true })
|
|
52
|
+
chmodSync(path, 0o700)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Entries every account shares, and whether each is a folder. Credentials (`auth.json`),
|
|
56
|
+
// sessions, and history are never listed.
|
|
57
|
+
const SHARED_ENTRIES: readonly (readonly [string, boolean])[] = [
|
|
58
|
+
['settings.json', false],
|
|
59
|
+
['models.json', false],
|
|
60
|
+
['AGENTS.md', false],
|
|
61
|
+
['SYSTEM.md', false],
|
|
62
|
+
['skills', true],
|
|
63
|
+
['prompts', true],
|
|
64
|
+
['extensions', true],
|
|
65
|
+
['themes', true],
|
|
66
|
+
// Where Pi keeps installed packages: one install serves every account.
|
|
67
|
+
['npm', true],
|
|
68
|
+
['git', true]
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export interface NativeConfig {
|
|
72
|
+
provider: string
|
|
73
|
+
path: string
|
|
74
|
+
entries: { name: string; directory: boolean; present: boolean }[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const present = (path: string): boolean => {
|
|
78
|
+
try {
|
|
79
|
+
lstatSync(path)
|
|
80
|
+
return true
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the accounts share, and which of it exists yet. Reads only; nothing is created. */
|
|
87
|
+
export function nativeConfig(accountsDir: string): NativeConfig {
|
|
88
|
+
const shared = sharedDir(accountsDir)
|
|
89
|
+
return { provider: 'pi', path: shared, entries: SHARED_ENTRIES.map(([name, directory]) => ({ name, directory, present: present(join(shared, name)) })) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The shared folder, created private if no account has linked it yet. */
|
|
93
|
+
export function ensureSharedDir(accountsDir: string): string {
|
|
94
|
+
const shared = sharedDir(accountsDir)
|
|
95
|
+
makePrivate(shared)
|
|
96
|
+
return shared
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const modified = (path: string): number => {
|
|
100
|
+
try {
|
|
101
|
+
return lstatSync(path).mtimeMs
|
|
102
|
+
} catch {
|
|
103
|
+
return -1
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Moves a real file or folder aside so the shared link can take its place. */
|
|
108
|
+
function backUp(path: string): void {
|
|
109
|
+
renameSync(path, join(dirname(path), `${basename(path)}.agentx-backup-${Math.floor(Date.now() / 1000)}`))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Links the account's shared entries to `_shared/pi/`. A real entry found in the account folder
|
|
114
|
+
* (from before sharing, or a tool that replaced the link while saving) becomes the shared copy
|
|
115
|
+
* when there is none or it is newer; otherwise it is kept as a backup.
|
|
116
|
+
*/
|
|
117
|
+
function linkShared(accountsDir: string, dir: string): void {
|
|
118
|
+
const shared = ensureSharedDir(accountsDir)
|
|
119
|
+
for (const [name, isDir] of SHARED_ENTRIES) {
|
|
120
|
+
const local = join(dir, name)
|
|
121
|
+
const target = join(shared, name)
|
|
122
|
+
let meta: ReturnType<typeof lstatSync> | null = null
|
|
123
|
+
try {
|
|
124
|
+
meta = lstatSync(local)
|
|
125
|
+
} catch {
|
|
126
|
+
meta = null
|
|
127
|
+
}
|
|
128
|
+
if (meta?.isSymbolicLink()) {
|
|
129
|
+
if (readlinkSync(local) === target) {
|
|
130
|
+
if (isDir) mkdirSync(target, { recursive: true })
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
unlinkSync(local)
|
|
134
|
+
} else if (meta) {
|
|
135
|
+
if (!present(target)) renameSync(local, target)
|
|
136
|
+
else if (!isDir && modified(local) > modified(target)) {
|
|
137
|
+
backUp(target)
|
|
138
|
+
renameSync(local, target)
|
|
139
|
+
} else backUp(local)
|
|
140
|
+
}
|
|
141
|
+
if (isDir) mkdirSync(target, { recursive: true })
|
|
142
|
+
symlinkSync(target, local)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Creates the account's runtime folder and links the shared entries into it. */
|
|
147
|
+
export function ensureHome(accountsDir: string, account: Pick<Account, 'id'>): string {
|
|
148
|
+
makePrivate(join(accountsDir, account.id))
|
|
149
|
+
makePrivate(homeDir(accountsDir, account.id))
|
|
150
|
+
const dir = configDir(accountsDir, account)
|
|
151
|
+
makePrivate(dir)
|
|
152
|
+
linkShared(accountsDir, dir)
|
|
153
|
+
return dir
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const inside = (path: string, parent: string): boolean => path === parent || path.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`)
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Refuses an agent folder that is (or is inside) a default tool folder, or that is not inside
|
|
160
|
+
* the harness's own accounts folder.
|
|
161
|
+
*/
|
|
162
|
+
export function assertIsolated(accountsDir: string, dir: string): void {
|
|
163
|
+
const defaults = ['.codex', '.claude', '.pi'].map((name) => join(homedir() || '/', name))
|
|
164
|
+
if (defaults.some((folder) => inside(dir, folder)) || !inside(dir, accountsDir)) {
|
|
165
|
+
throw new HarnessError('account_required', 'A session cannot start outside its isolated account folder.')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Variables to set, or remove (`null`), when launching a session for this account. */
|
|
170
|
+
export function launchEnv(accountsDir: string, account: Pick<Account, 'id'>, inherited: NodeJS.ProcessEnv = process.env): Record<string, string | null> {
|
|
171
|
+
const dir = ensureHome(accountsDir, account)
|
|
172
|
+
assertIsolated(accountsDir, dir)
|
|
173
|
+
const env: Record<string, string | null> = {}
|
|
174
|
+
for (const name of inheritedCredentialVars(inherited)) env[name] = null
|
|
175
|
+
env.PI_CODING_AGENT_DIR = dir
|
|
176
|
+
return env
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What a session needs to run for an account. */
|
|
180
|
+
export interface AccountContext {
|
|
181
|
+
id: string
|
|
182
|
+
provider: 'pi'
|
|
183
|
+
label: string
|
|
184
|
+
/** Variables to set; `null` removes an inherited one. */
|
|
185
|
+
env: Record<string, string | null>
|
|
186
|
+
/** The account's own agent folder. */
|
|
187
|
+
configDir: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function accountContext(accountsDir: string, account: Account): AccountContext {
|
|
191
|
+
return { id: account.id, provider: 'pi', label: account.label, env: launchEnv(accountsDir, account), configDir: configDir(accountsDir, account) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The process environment for a `pi` process launched on behalf of this account. */
|
|
195
|
+
export function processEnv(account: AccountContext, inherited: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
196
|
+
const env: Record<string, string> = {}
|
|
197
|
+
for (const [name, value] of Object.entries(inherited)) if (value !== undefined) env[name] = value
|
|
198
|
+
for (const [name, value] of Object.entries(account.env)) {
|
|
199
|
+
if (value === null) delete env[name]
|
|
200
|
+
else env[name] = value
|
|
201
|
+
}
|
|
202
|
+
return env
|
|
203
|
+
}
|
|
204
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Where credentials live. The harness never writes a secret to its own files: it asks the
|
|
2
|
+
// store the embedding application provides (a Keychain, an encrypted vault), by key.
|
|
3
|
+
import { chmodSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { atomicWrite, readJsonOr } from '../core/fs'
|
|
7
|
+
|
|
8
|
+
export interface SecretStore {
|
|
9
|
+
get(key: string): Promise<string | null>
|
|
10
|
+
set(key: string, value: string): Promise<void>
|
|
11
|
+
delete(key: string): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `<account id>/<secret name>`: the key an account's secret is stored under. */
|
|
15
|
+
export const secretKey = (accountId: string, name: string): string => `${accountId}/${name}`
|
|
16
|
+
|
|
17
|
+
/** Secrets held only for the life of the process. For tests and throwaway runs. */
|
|
18
|
+
export function memorySecretStore(initial: Record<string, string> = {}): SecretStore & { values: Map<string, string> } {
|
|
19
|
+
const values = new Map(Object.entries(initial))
|
|
20
|
+
return {
|
|
21
|
+
values,
|
|
22
|
+
get: async (key) => values.get(key) ?? null,
|
|
23
|
+
set: async (key, value) => void values.set(key, value),
|
|
24
|
+
delete: async (key) => void values.delete(key)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Secrets in one owner-only JSON file. For a host with no Keychain to offer; anything that can
|
|
30
|
+
* read the file can read every secret in it.
|
|
31
|
+
*/
|
|
32
|
+
export function fileSecretStore(path: string): SecretStore {
|
|
33
|
+
const read = (): Record<string, string> => readJsonOr<Record<string, string>>(path, {})
|
|
34
|
+
const write = (values: Record<string, string>): void => {
|
|
35
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
36
|
+
atomicWrite(path, `${JSON.stringify(values, null, 2)}\n`)
|
|
37
|
+
chmodSync(path, 0o600)
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
get: async (key) => read()[key] ?? null,
|
|
41
|
+
set: async (key, value) => write({ ...read(), [key]: value }),
|
|
42
|
+
delete: async (key) => {
|
|
43
|
+
const values = read()
|
|
44
|
+
if (key in values) {
|
|
45
|
+
delete values[key]
|
|
46
|
+
write(values)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Accounts. One profile (settings, registry, memory) and any number of Pi accounts, each with
|
|
2
|
+
// its own isolated agent folder, stored credentials, sign-in state, limits, health, and audit
|
|
3
|
+
// trail.
|
|
4
|
+
//
|
|
5
|
+
// Device-local layout under `<state dir>/accounts/`:
|
|
6
|
+
// accounts.json accounts, the default, pools (no secrets)
|
|
7
|
+
// <id>/state.json health, plan, limits, last error (no secrets)
|
|
8
|
+
// <id>/home/pi/ Pi's own agent folder for this account
|
|
9
|
+
// _shared/pi/ settings, skills, prompts linked into every account's agent folder
|
|
10
|
+
// audit.jsonl append-only account actions (secrets scrubbed)
|
|
11
|
+
//
|
|
12
|
+
// Switching is always manual. A pool only chooses an account when a session starts; a running
|
|
13
|
+
// or resumed session keeps the account it started with.
|
|
14
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
18
|
+
import { appendLine, newId, now, readJsonOr, safeId, writeJson } from '../core/fs'
|
|
19
|
+
import { charCount, compareText, lines, takeChars } from '../core/text'
|
|
20
|
+
|
|
21
|
+
/** The only provider the harness runs. Records of other providers are kept in the file and ignored. */
|
|
22
|
+
export const PROVIDER = 'pi'
|
|
23
|
+
|
|
24
|
+
export interface Account {
|
|
25
|
+
id: string
|
|
26
|
+
provider: string
|
|
27
|
+
label: string
|
|
28
|
+
/** `subscription` (a provider's own sign-in) or `apiKey`. */
|
|
29
|
+
authKind: string
|
|
30
|
+
credentialStore?: string
|
|
31
|
+
createdAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Pool {
|
|
35
|
+
id: string
|
|
36
|
+
provider: string
|
|
37
|
+
label: string
|
|
38
|
+
accountIds: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Registry {
|
|
42
|
+
accounts: Account[]
|
|
43
|
+
defaults: Record<string, string>
|
|
44
|
+
pools: Pool[]
|
|
45
|
+
migratedProviderKeys: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Limit {
|
|
49
|
+
id: string
|
|
50
|
+
label: string
|
|
51
|
+
usedPercent?: number | null
|
|
52
|
+
resetsAt?: string | null
|
|
53
|
+
windowMinutes?: number | null
|
|
54
|
+
/** `allowed`, `warning`, or `rejected` when the provider reports it. */
|
|
55
|
+
status?: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Issue {
|
|
59
|
+
/** `auth_expired`, `rate_limited`, `quota`, `network`, or `unknown`. */
|
|
60
|
+
category: string
|
|
61
|
+
message: string
|
|
62
|
+
at: string
|
|
63
|
+
resetsAt: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AccountState {
|
|
67
|
+
/** `ok`, `limited`, `auth_expired`, `signed_out`, `error`, or `unknown`. */
|
|
68
|
+
health: string
|
|
69
|
+
signedIn: boolean | null
|
|
70
|
+
authMethod: string | null
|
|
71
|
+
plan: string | null
|
|
72
|
+
identity: string | null
|
|
73
|
+
limits: Limit[]
|
|
74
|
+
lastError: Issue | null
|
|
75
|
+
lastCheckedAt: string | null
|
|
76
|
+
cliVersion: string | null
|
|
77
|
+
/** Features detected for this account, e.g. `models:12`, `provider:anthropic`. */
|
|
78
|
+
capabilities: string[]
|
|
79
|
+
/** Providers with a stored credential (names only). */
|
|
80
|
+
piProviders: string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const emptyState = (): AccountState => ({ health: 'unknown', signedIn: null, authMethod: null, plan: null, identity: null, limits: [], lastError: null, lastCheckedAt: null, cliVersion: null, capabilities: [], piProviders: [] })
|
|
84
|
+
|
|
85
|
+
export type AccountView = Account & { isDefault: boolean; state: AccountState }
|
|
86
|
+
|
|
87
|
+
export interface AccountsOverview {
|
|
88
|
+
accounts: AccountView[]
|
|
89
|
+
pools: Pool[]
|
|
90
|
+
defaults: Record<string, string>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AuditEntry {
|
|
94
|
+
at: string
|
|
95
|
+
accountId: string | null
|
|
96
|
+
action: string
|
|
97
|
+
detail: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- Files ------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
const registryPath = (dir: string): string => join(dir, 'accounts.json')
|
|
103
|
+
const statePath = (dir: string, accountId: string): string => join(dir, safeId(accountId), 'state.json')
|
|
104
|
+
|
|
105
|
+
export function loadRegistry(dir: string): Registry {
|
|
106
|
+
const stored = readJsonOr<Partial<Registry>>(registryPath(dir), {}) ?? {}
|
|
107
|
+
return { accounts: stored.accounts ?? [], defaults: stored.defaults ?? {}, pools: stored.pools ?? [], migratedProviderKeys: stored.migratedProviderKeys ?? false }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function saveRegistry(dir: string, registry: Registry): void {
|
|
111
|
+
mkdirSync(dir, { recursive: true })
|
|
112
|
+
writeJson(registryPath(dir), registry)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function loadState(dir: string, accountId: string): AccountState {
|
|
116
|
+
try {
|
|
117
|
+
return { ...emptyState(), ...(readJsonOr<Partial<AccountState>>(statePath(dir, accountId), {}) ?? {}) }
|
|
118
|
+
} catch {
|
|
119
|
+
return emptyState()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function saveState(dir: string, accountId: string, state: AccountState): void {
|
|
124
|
+
writeJson(statePath(dir, accountId), state)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Replaces anything that looks like a token so audit lines and stored errors never hold secrets. */
|
|
128
|
+
export function scrub(text: string): string {
|
|
129
|
+
const words = text.split(' ').map((word) => {
|
|
130
|
+
const bytes = Buffer.byteLength(word)
|
|
131
|
+
const tokenLike = bytes >= 24 && (word.match(/[A-Za-z0-9]/g)?.length ?? 0) * 10 >= bytes * 8
|
|
132
|
+
return tokenLike || word.startsWith('sk-') || word.startsWith('sess-') ? '[redacted]' : word
|
|
133
|
+
})
|
|
134
|
+
return takeChars(words.join(' '), 500)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function audit(dir: string, accountId: string | null, action: string, detail: string): void {
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(dir, { recursive: true })
|
|
140
|
+
appendLine(join(dir, 'audit.jsonl'), JSON.stringify({ at: now(), accountId, action, detail: scrub(detail) } satisfies AuditEntry))
|
|
141
|
+
} catch {
|
|
142
|
+
// An audit line that cannot be written never fails the action it describes.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The newest audit entries first, optionally for one account. */
|
|
147
|
+
export function readAudit(dir: string, accountId?: string | null, limit = 100): AuditEntry[] {
|
|
148
|
+
let text = ''
|
|
149
|
+
try {
|
|
150
|
+
text = readFileSync(join(dir, 'audit.jsonl'), 'utf8')
|
|
151
|
+
} catch {
|
|
152
|
+
text = ''
|
|
153
|
+
}
|
|
154
|
+
const entries = lines(text).flatMap((line) => {
|
|
155
|
+
try {
|
|
156
|
+
const entry = JSON.parse(line) as AuditEntry
|
|
157
|
+
return entry && typeof entry.at === 'string' && typeof entry.action === 'string' && (!accountId || entry.accountId === accountId) ? [entry] : []
|
|
158
|
+
} catch {
|
|
159
|
+
return []
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
return entries.reverse().slice(0, Math.min(limit, 1000))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- Health -----------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function inFuture(timestamp: string | null | undefined): boolean {
|
|
168
|
+
const time = timestamp ? Date.parse(timestamp) : Number.NaN
|
|
169
|
+
return !Number.isNaN(time) && time > Date.now()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// How long a limit error that came without a reset time keeps the account limited. Providers
|
|
173
|
+
// report no limit windows, so nothing else would ever lift it.
|
|
174
|
+
const UNTIMED_LIMIT_ERROR_SECS = 3600
|
|
175
|
+
|
|
176
|
+
export const isLimitCategory = (category: string): boolean => category === 'rate_limited' || category === 'quota'
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether a `rate_limited` or `quota` error still counts. With a reset time it counts until
|
|
180
|
+
* then; without one it counts for a short while after it was recorded.
|
|
181
|
+
*/
|
|
182
|
+
export function limitErrorActive(issue: Issue): boolean {
|
|
183
|
+
if (issue.resetsAt) return inFuture(issue.resetsAt)
|
|
184
|
+
const at = Date.parse(issue.at)
|
|
185
|
+
return !Number.isNaN(at) && Math.trunc((Date.now() - at) / 1000) < UNTIMED_LIMIT_ERROR_SECS
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function deriveHealth(state: AccountState): string {
|
|
189
|
+
const limitedByWindow = state.limits.some((limit) => (limit.status === 'rejected' || (limit.usedPercent ?? 0) >= 100) && (!limit.resetsAt || inFuture(limit.resetsAt)))
|
|
190
|
+
const issue = state.lastError
|
|
191
|
+
if (issue?.category === 'auth_expired') return 'auth_expired'
|
|
192
|
+
if (issue && isLimitCategory(issue.category) && limitErrorActive(issue)) return 'limited'
|
|
193
|
+
if (state.signedIn === false) return 'signed_out'
|
|
194
|
+
if (limitedByWindow) return 'limited'
|
|
195
|
+
if (issue && !isLimitCategory(issue.category)) return 'error'
|
|
196
|
+
return state.signedIn === true ? 'ok' : 'unknown'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Read-modify-write on one account's state. Returns the previous health and the new state. */
|
|
200
|
+
export function updateState(dir: string, accountId: string, change: (state: AccountState) => void): { previous: string; state: AccountState } {
|
|
201
|
+
const state = loadState(dir, accountId)
|
|
202
|
+
const previous = state.health
|
|
203
|
+
change(state)
|
|
204
|
+
state.health = deriveHealth(state)
|
|
205
|
+
saveState(dir, accountId, state)
|
|
206
|
+
return { previous, state }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- Lookup and pinning -----------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export function find(dir: string, accountId: string): Account {
|
|
212
|
+
const account = loadRegistry(dir).accounts.find((entry) => entry.id === accountId)
|
|
213
|
+
if (!account) throw new HarnessError('account_required', "This session's account was removed.", 'Choose another account for it.')
|
|
214
|
+
return account
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const usable = (state: AccountState): boolean => state.health === 'ok' || state.health === 'unknown'
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Chooses the account for a new session: the explicit account, else the pool's first usable
|
|
221
|
+
* account, else the default, else the first account. It never skips to another one silently.
|
|
222
|
+
*/
|
|
223
|
+
export function resolve(dir: string, accountId?: string | null, poolId?: string | null): Account {
|
|
224
|
+
const registry = loadRegistry(dir)
|
|
225
|
+
if (accountId) {
|
|
226
|
+
const account = find(dir, accountId)
|
|
227
|
+
if (account.provider !== PROVIDER) throw invalid('That account belongs to another provider.')
|
|
228
|
+
return account
|
|
229
|
+
}
|
|
230
|
+
if (poolId) {
|
|
231
|
+
const pool = registry.pools.find((entry) => entry.id === poolId)
|
|
232
|
+
if (!pool) throw notFound('Pool')
|
|
233
|
+
const members = pool.accountIds.flatMap((id) => registry.accounts.find((account) => account.id === id) ?? [])
|
|
234
|
+
const ready = members.find((account) => usable(loadState(dir, account.id)))
|
|
235
|
+
if (ready) return ready
|
|
236
|
+
const resets = members.flatMap((account) => {
|
|
237
|
+
const state = loadState(dir, account.id)
|
|
238
|
+
return [...state.limits.map((limit) => limit.resetsAt), state.lastError?.resetsAt].filter((reset): reset is string => Boolean(reset))
|
|
239
|
+
})
|
|
240
|
+
const earliest = resets.sort(compareText)[0]
|
|
241
|
+
throw new HarnessError('account_unavailable', `Every account in ${pool.label} is unavailable.`, earliest ? `The earliest reset is ${earliest}.` : 'Reauthenticate an account in Accounts.')
|
|
242
|
+
}
|
|
243
|
+
const account = registry.accounts.find((entry) => entry.id === registry.defaults[PROVIDER]) ?? registry.accounts.find((entry) => entry.provider === PROVIDER)
|
|
244
|
+
if (!account) throw new HarnessError('account_required', 'Add a Pi account first.', 'Open Accounts (⌘4) to sign in.')
|
|
245
|
+
return account
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The default account, if any exists. */
|
|
249
|
+
export function defaultAccount(dir: string): Account | null {
|
|
250
|
+
try {
|
|
251
|
+
return resolve(dir)
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function summaryAuthentication(dir: string): 'authenticated' | 'unauthenticated' | 'unknown' {
|
|
258
|
+
const states = loadRegistry(dir).accounts.filter((account) => account.provider === PROVIDER).map((account) => loadState(dir, account.id))
|
|
259
|
+
if (states.some((state) => state.signedIn === true)) return 'authenticated'
|
|
260
|
+
return states.some((state) => state.signedIn === false) ? 'unauthenticated' : 'unknown'
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function overview(dir: string): AccountsOverview {
|
|
264
|
+
const registry = loadRegistry(dir)
|
|
265
|
+
return {
|
|
266
|
+
accounts: registry.accounts.filter((account) => account.provider === PROVIDER).map((account) => ({ ...account, isDefault: registry.defaults[account.provider] === account.id, state: loadState(dir, account.id) })),
|
|
267
|
+
pools: registry.pools.filter((pool) => pool.provider === PROVIDER),
|
|
268
|
+
defaults: registry.defaults
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- Changes ----------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export function cleanLabel(label: string): string {
|
|
275
|
+
const cleaned = label.trim()
|
|
276
|
+
if (!cleaned || charCount(cleaned) > 60) throw invalid('Give the account a name of 1–60 characters.')
|
|
277
|
+
return cleaned
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function addAccount(dir: string, label: string, authKind: string): Account {
|
|
281
|
+
if (authKind !== 'subscription' && authKind !== 'apiKey') throw invalid('Unknown sign-in type.')
|
|
282
|
+
const account: Account = { id: newId(), provider: PROVIDER, label: cleanLabel(label), authKind, credentialStore: 'keyring', createdAt: now() }
|
|
283
|
+
const registry = loadRegistry(dir)
|
|
284
|
+
registry.defaults[PROVIDER] ??= account.id
|
|
285
|
+
registry.accounts.push(account)
|
|
286
|
+
saveRegistry(dir, registry)
|
|
287
|
+
return account
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function renameAccount(dir: string, accountId: string, label: string): void {
|
|
291
|
+
const cleaned = cleanLabel(label)
|
|
292
|
+
const registry = loadRegistry(dir)
|
|
293
|
+
const account = registry.accounts.find((entry) => entry.id === accountId)
|
|
294
|
+
if (!account) throw notFound('Account')
|
|
295
|
+
account.label = cleaned
|
|
296
|
+
saveRegistry(dir, registry)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function setDefault(dir: string, accountId: string): Account {
|
|
300
|
+
const account = find(dir, accountId)
|
|
301
|
+
const registry = loadRegistry(dir)
|
|
302
|
+
registry.defaults[account.provider] = account.id
|
|
303
|
+
saveRegistry(dir, registry)
|
|
304
|
+
return account
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Drops the account from the registry, its pools, and the default. Its folder and secrets are the caller's to remove. */
|
|
308
|
+
export function forgetAccount(dir: string, account: Account): void {
|
|
309
|
+
const registry = loadRegistry(dir)
|
|
310
|
+
registry.accounts = registry.accounts.filter((entry) => entry.id !== account.id)
|
|
311
|
+
if (registry.defaults[account.provider] === account.id) {
|
|
312
|
+
const next = registry.accounts.find((entry) => entry.provider === account.provider)
|
|
313
|
+
if (next) registry.defaults[account.provider] = next.id
|
|
314
|
+
else delete registry.defaults[account.provider]
|
|
315
|
+
}
|
|
316
|
+
for (const pool of registry.pools) pool.accountIds = pool.accountIds.filter((id) => id !== account.id)
|
|
317
|
+
saveRegistry(dir, registry)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function savePool(dir: string, pool: { id?: string; label: string; accountIds: string[] }): Pool {
|
|
321
|
+
const label = cleanLabel(pool.label)
|
|
322
|
+
const registry = loadRegistry(dir)
|
|
323
|
+
for (const id of pool.accountIds) {
|
|
324
|
+
const account = registry.accounts.find((entry) => entry.id === id)
|
|
325
|
+
if (!account) throw notFound('Account')
|
|
326
|
+
if (account.provider !== PROVIDER) throw invalid('A pool holds accounts of one provider.')
|
|
327
|
+
}
|
|
328
|
+
const next: Pool = { id: pool.id ? safeId(pool.id) : newId(), provider: PROVIDER, label, accountIds: [...pool.accountIds] }
|
|
329
|
+
registry.pools = [...registry.pools.filter((existing) => existing.id !== next.id), next]
|
|
330
|
+
saveRegistry(dir, registry)
|
|
331
|
+
return next
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function removePool(dir: string, poolId: string): void {
|
|
335
|
+
const registry = loadRegistry(dir)
|
|
336
|
+
registry.pools = registry.pools.filter((pool) => pool.id !== poolId)
|
|
337
|
+
saveRegistry(dir, registry)
|
|
338
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A generic view of what is available to a caller. The existing plugin, registry, and connector
|
|
2
|
+
// stores remain the source of truth; this module gives hosts one capability-shaped contract.
|
|
3
|
+
import { invalid } from '../core/errors'
|
|
4
|
+
import type { Harness } from '../harness'
|
|
5
|
+
import type { CallerContext } from '../memory/caller'
|
|
6
|
+
import type { Launch } from '../prompt/compile'
|
|
7
|
+
import * as pluginApi from '../plugins/api'
|
|
8
|
+
import type { Plugin } from '../plugins/types'
|
|
9
|
+
import type { Capability, CapabilityCatalogue, CapabilityScope, CapabilitySet } from './types'
|
|
10
|
+
|
|
11
|
+
export interface ListParams extends CapabilityScope {}
|
|
12
|
+
|
|
13
|
+
export interface InventoryParams extends CapabilityScope {
|
|
14
|
+
context?: CallerContext
|
|
15
|
+
launch?: Launch
|
|
16
|
+
tools?: string[]
|
|
17
|
+
connections?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const unique = (values: readonly string[]): string[] => [...new Set(values.filter((value) => value.length > 0))]
|
|
21
|
+
|
|
22
|
+
function contributions(plugins: readonly Plugin[]): Capability[] {
|
|
23
|
+
return plugins.flatMap((plugin) => plugin.contributions.map((item) => ({
|
|
24
|
+
id: item.id,
|
|
25
|
+
pluginId: plugin.id,
|
|
26
|
+
kind: item.kind,
|
|
27
|
+
name: item.name,
|
|
28
|
+
description: item.description,
|
|
29
|
+
source: plugin.source,
|
|
30
|
+
state: item.state,
|
|
31
|
+
attachment: item.attachment
|
|
32
|
+
})))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function list(harness: Harness, params: ListParams = {}): Promise<CapabilityCatalogue> {
|
|
36
|
+
const plugins = await pluginApi.list(harness, params)
|
|
37
|
+
return { plugins, capabilities: contributions(plugins) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The resolved callable set for one caller, based on the stores and caller-supplied tool list. */
|
|
41
|
+
export async function resolve(harness: Harness, params: ListParams & { tools?: readonly string[] }): Promise<CapabilitySet> {
|
|
42
|
+
const caller = { agentId: params.agentId ?? null, projectId: params.projectId ?? null }
|
|
43
|
+
const catalogue = await list(harness, params)
|
|
44
|
+
const connectors = harness.apps.connections.forScope(caller).map((connection) => connection.id)
|
|
45
|
+
return {
|
|
46
|
+
version: 1,
|
|
47
|
+
tools: unique(params.tools ?? []),
|
|
48
|
+
connectors: unique(connectors),
|
|
49
|
+
contributions: catalogue.capabilities.filter((item) => item.state === 'on').map((item) => item.id)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toolIntersection(parent: CapabilitySet, assignee: CapabilitySet, taskTools: readonly string[] = []): CapabilitySet {
|
|
54
|
+
const permitted = new Set(taskTools.length ? taskTools : assignee.tools)
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
tools: parent.tools.filter((tool) => assignee.tools.includes(tool) && permitted.has(tool)),
|
|
58
|
+
connectors: parent.connectors.filter((id) => assignee.connectors.includes(id)),
|
|
59
|
+
contributions: parent.contributions.filter((id) => assignee.contributions.includes(id))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function inventory(harness: Harness, params: InventoryParams = {}) {
|
|
64
|
+
return pluginApi.inventoryFor(harness, params)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function attach(harness: Harness, params: pluginApi.AttachParams, on: boolean): Promise<CapabilityCatalogue> {
|
|
68
|
+
const plugins = await pluginApi.attach(harness, params, on)
|
|
69
|
+
return { plugins, capabilities: contributions(plugins) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function switchCapability(harness: Harness, params: pluginApi.AttachParams, on: boolean): Promise<CapabilityCatalogue> {
|
|
73
|
+
if (!params.id.trim()) throw invalid('A capability id is required.')
|
|
74
|
+
return attach(harness, params, on)
|
|
75
|
+
}
|