@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.
Files changed (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. 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
+ }