@xingular/harness 0.0.0-stage → 0.1.1
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 +11510 -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.test.ts +95 -0
- package/src/accounts/env.ts +338 -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 +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -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 +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -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 +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -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 +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -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 +407 -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,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 = taskTools === undefined ? null : new Set(taskTools)
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
tools: parent.tools.filter((tool) => assignee.tools.includes(tool) && (!permitted || 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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { HarnessError, invalid } from '../core/errors'
|
|
5
|
+
import { readJson, safeId, writeJson } from '../core/fs'
|
|
6
|
+
import type { CapabilitySet } from './types'
|
|
7
|
+
|
|
8
|
+
export interface RunCapabilitySnapshot {
|
|
9
|
+
version: 1
|
|
10
|
+
caller: { agentId: string | null; projectId: string | null }
|
|
11
|
+
tools: string[]
|
|
12
|
+
connectors: string[]
|
|
13
|
+
contributions: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const path = (root: string, runId: string): string => join(root, 'runs', safeId(runId), 'capabilities.json')
|
|
17
|
+
|
|
18
|
+
function strings(value: unknown): string[] {
|
|
19
|
+
return Array.isArray(value) ? [...new Set(value.filter((item): item is string => typeof item === 'string' && item.length > 0))] : []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function capabilitySet(value: unknown): CapabilitySet {
|
|
23
|
+
if (!value || typeof value !== 'object' || (value as { version?: unknown }).version !== 1) throw invalid('A capability set must use version 1.')
|
|
24
|
+
const record = value as Record<string, unknown>
|
|
25
|
+
return { version: 1, tools: strings(record.tools), connectors: strings(record.connectors), contributions: strings(record.contributions) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Writes a run's capability set once. A resumed or duplicate launch cannot replace it. */
|
|
29
|
+
export function freezeRun(root: string, runId: string, caller: { agentId?: string | null; projectId?: string | null }, set: CapabilitySet): RunCapabilitySnapshot {
|
|
30
|
+
const target = path(root, runId)
|
|
31
|
+
const snapshot: RunCapabilitySnapshot = {
|
|
32
|
+
version: 1,
|
|
33
|
+
caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null },
|
|
34
|
+
tools: strings(set.tools),
|
|
35
|
+
connectors: strings(set.connectors),
|
|
36
|
+
contributions: strings(set.contributions)
|
|
37
|
+
}
|
|
38
|
+
const existing = existsSync(target) ? readJson<RunCapabilitySnapshot>(target) : null
|
|
39
|
+
if (existing) {
|
|
40
|
+
if (JSON.stringify(existing) !== JSON.stringify(snapshot)) throw new HarnessError('capabilities_frozen', 'This run already has a different frozen capability set.')
|
|
41
|
+
return existing
|
|
42
|
+
}
|
|
43
|
+
writeJson(target, snapshot)
|
|
44
|
+
return snapshot
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadRun(root: string, runId: string): RunCapabilitySnapshot | null {
|
|
48
|
+
const stored = readJson<unknown>(path(root, runId))
|
|
49
|
+
if (!stored || typeof stored !== 'object' || (stored as { version?: unknown }).version !== 1) return null
|
|
50
|
+
const record = stored as Record<string, unknown>
|
|
51
|
+
const caller = record.caller && typeof record.caller === 'object' ? record.caller as Record<string, unknown> : {}
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
caller: { agentId: typeof caller.agentId === 'string' ? caller.agentId : null, projectId: typeof caller.projectId === 'string' ? caller.projectId : null },
|
|
55
|
+
tools: strings(record.tools),
|
|
56
|
+
connectors: strings(record.connectors),
|
|
57
|
+
contributions: strings(record.contributions)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AttachScope, Attachment } from '../plugins/attachments'
|
|
2
|
+
import type { ContributionKind, ContributionState, PluginSource } from '../plugins/types'
|
|
3
|
+
|
|
4
|
+
export type CapabilityKind = ContributionKind | 'plugin'
|
|
5
|
+
|
|
6
|
+
/** One item the caller can switch on or off, or that a plugin makes available. */
|
|
7
|
+
export interface Capability {
|
|
8
|
+
id: string
|
|
9
|
+
pluginId: string
|
|
10
|
+
kind: CapabilityKind
|
|
11
|
+
name: string
|
|
12
|
+
description: string
|
|
13
|
+
source: PluginSource
|
|
14
|
+
state: ContributionState
|
|
15
|
+
attachment: Attachment | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CapabilityCatalogue {
|
|
19
|
+
plugins: import('../plugins/types').Plugin[]
|
|
20
|
+
capabilities: Capability[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The callable capabilities a run starts with. Permission to run still goes through the gate. */
|
|
24
|
+
export interface CapabilitySet {
|
|
25
|
+
version: 1
|
|
26
|
+
tools: string[]
|
|
27
|
+
connectors: string[]
|
|
28
|
+
contributions: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CapabilityScope extends AttachScope {
|
|
32
|
+
cwd?: string | null
|
|
33
|
+
accountId?: string | null
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `xingular-harness` command.
|
|
3
|
+
//
|
|
4
|
+
// xingular-harness serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]…
|
|
5
|
+
// [--host-secrets] [--host-policy] [--host-tools] [--host-directory]
|
|
6
|
+
// Serves the harness to a host process over stdin/stdout (newline-delimited JSON).
|
|
7
|
+
// xingular-harness wiki <command> Memory and Wiki, from inside a terminal session.
|
|
8
|
+
// xingular-harness mcp <command> Connected apps, from inside a terminal session.
|
|
9
|
+
// xingular-harness plugins <command> List, install, switch and trust plugins.
|
|
10
|
+
import { log } from './core/bus'
|
|
11
|
+
import { createHarness, type HarnessOptions } from './harness'
|
|
12
|
+
import { runPluginsCli, terminalAsk } from './plugins/cli'
|
|
13
|
+
import { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
|
|
14
|
+
import { RpcPeer, stdioTransport } from './server/rpc'
|
|
15
|
+
import { attach } from './server/serve'
|
|
16
|
+
import { runAppsCli, runWikiCli } from './terminal/cli'
|
|
17
|
+
import { runStandaloneTerminal, standaloneArguments } from './terminal/standalone'
|
|
18
|
+
|
|
19
|
+
const USAGE = `Usage: xingular-harness [--data <dir>] [--state <dir>]
|
|
20
|
+
xingular-harness <terminal | serve | wiki | mcp | plugins> [arguments]
|
|
21
|
+
|
|
22
|
+
(no command) Start the interactive Pi terminal
|
|
23
|
+
terminal Start the interactive Pi terminal
|
|
24
|
+
serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]
|
|
25
|
+
[--host-secrets] [--host-policy] [--host-tools] [--host-directory]
|
|
26
|
+
Serve the harness over newline-delimited JSON
|
|
27
|
+
wiki <command> Memory and Wiki commands for a terminal session
|
|
28
|
+
mcp <command> Connected-app commands for a terminal session
|
|
29
|
+
plugins <command> List, install, switch and trust plugins (plugins help)
|
|
30
|
+
|
|
31
|
+
Standalone terminal options:
|
|
32
|
+
--data <dir> Data directory (default: ~/.xingular-harness/data)
|
|
33
|
+
--state <dir> State directory (default: ~/.xingular-harness/state)
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
function flags(argv: string[]): { values: Record<string, string[]>; switches: Set<string> } {
|
|
37
|
+
const values: Record<string, string[]> = {}
|
|
38
|
+
const switches = new Set<string>()
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const name = argv[index]
|
|
41
|
+
if (!name.startsWith('--')) continue
|
|
42
|
+
const next = argv[index + 1]
|
|
43
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
44
|
+
;(values[name.slice(2)] ??= []).push(next)
|
|
45
|
+
index += 1
|
|
46
|
+
} else switches.add(name.slice(2))
|
|
47
|
+
}
|
|
48
|
+
return { values, switches }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function serveCommand(argv: string[]): Promise<number> {
|
|
52
|
+
const { values, switches } = flags(argv)
|
|
53
|
+
const stateDir = values.state?.[0]
|
|
54
|
+
if (!stateDir) {
|
|
55
|
+
process.stderr.write('serve needs --state <dir>.\n')
|
|
56
|
+
return Promise.resolve(2)
|
|
57
|
+
}
|
|
58
|
+
const peer = new RpcPeer(stdioTransport())
|
|
59
|
+
const options: HarnessOptions = {
|
|
60
|
+
stateDir,
|
|
61
|
+
dataRoot: values.data?.[0] ?? null,
|
|
62
|
+
productName: values.name?.[0],
|
|
63
|
+
builtins: values.builtins,
|
|
64
|
+
...(switches.has('host-secrets') ? { secrets: remoteSecrets(peer) } : {}),
|
|
65
|
+
...(switches.has('host-policy') ? { policy: remotePolicy(peer) } : {}),
|
|
66
|
+
...(switches.has('host-directory') ? { directory: remoteDirectory(peer) } : {})
|
|
67
|
+
}
|
|
68
|
+
const harness = createHarness(options)
|
|
69
|
+
if (switches.has('host-tools')) harness.sessions.provide(remoteTools(peer))
|
|
70
|
+
attach(harness, peer)
|
|
71
|
+
return new Promise<number>((resolve) => {
|
|
72
|
+
let closing = false
|
|
73
|
+
const shutdown = (): void => {
|
|
74
|
+
if (closing) return
|
|
75
|
+
closing = true
|
|
76
|
+
void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5000))]).then(() => resolve(0))
|
|
77
|
+
}
|
|
78
|
+
peer.handle('harness.shutdown', () => void setImmediate(shutdown))
|
|
79
|
+
process.stdin.on('end', shutdown)
|
|
80
|
+
process.on('SIGTERM', shutdown)
|
|
81
|
+
process.on('SIGINT', shutdown)
|
|
82
|
+
peer.send({ notice: { type: 'ready' } })
|
|
83
|
+
log('ready')
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main(): Promise<number> {
|
|
88
|
+
const argv = process.argv.slice(2)
|
|
89
|
+
const [command, ...rest] = argv
|
|
90
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
91
|
+
process.stdout.write(USAGE)
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
const terminalArgs = standaloneArguments(argv)
|
|
95
|
+
if (terminalArgs !== null) return runStandaloneTerminal(terminalArgs)
|
|
96
|
+
if (command === 'serve') return serveCommand(rest)
|
|
97
|
+
if (command === 'wiki') return runWikiCli(rest)
|
|
98
|
+
if (command === 'mcp') return runAppsCli(rest)
|
|
99
|
+
if (command === 'plugins') return runPluginsCli(rest, { ask: terminalAsk() })
|
|
100
|
+
process.stdout.write(USAGE)
|
|
101
|
+
return command ? 2 : 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
void main().then(
|
|
105
|
+
(code) => process.exit(code),
|
|
106
|
+
(error) => {
|
|
107
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
)
|
package/src/core/bus.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Notices: what the harness tells whoever embeds it. Run events travel separately, through the
|
|
2
|
+
// run tracker; a notice says that some state changed and should be read again.
|
|
3
|
+
|
|
4
|
+
export interface Notice {
|
|
5
|
+
/** `accounts.changed`, `accounts.login`, `accounts.loginEvent`, `wiki.changed`, `wiki.maintenance`, … */
|
|
6
|
+
type: string
|
|
7
|
+
[key: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type NoticeListener = (notice: Notice) => void
|
|
11
|
+
|
|
12
|
+
export class Bus {
|
|
13
|
+
private readonly listeners = new Set<NoticeListener>()
|
|
14
|
+
|
|
15
|
+
on(listener: NoticeListener): () => void {
|
|
16
|
+
this.listeners.add(listener)
|
|
17
|
+
return () => this.listeners.delete(listener)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
emit(notice: Notice): void {
|
|
21
|
+
for (const listener of this.listeners) {
|
|
22
|
+
try {
|
|
23
|
+
listener(notice)
|
|
24
|
+
} catch (error) {
|
|
25
|
+
log('a notice listener failed', error instanceof Error ? error.message : String(error))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Diagnostics go to stderr; stdout may be carrying a protocol. */
|
|
32
|
+
export function log(...parts: unknown[]): void {
|
|
33
|
+
process.stderr.write(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}\n`)
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A note that something in a Wiki was written: a fact, a memory block, or a page. The memory
|
|
2
|
+
// code calls `noteChange` after the file is in place; whoever wants to react listens. Listeners
|
|
3
|
+
// run inline and must only take the note down: a write never waits for, or fails with, a listener.
|
|
4
|
+
export interface Change {
|
|
5
|
+
/** The Wiki folder the entry is in. */
|
|
6
|
+
wiki: string
|
|
7
|
+
id: string
|
|
8
|
+
/** The file that was written. */
|
|
9
|
+
path: string
|
|
10
|
+
/** Its new contents, front matter included. */
|
|
11
|
+
text: string
|
|
12
|
+
actor: string
|
|
13
|
+
reason: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Listener = (change: Change) => void
|
|
17
|
+
|
|
18
|
+
const listeners = new Set<Listener>()
|
|
19
|
+
|
|
20
|
+
export function noteChange(change: Change): void {
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
try {
|
|
23
|
+
listener(change)
|
|
24
|
+
} catch {
|
|
25
|
+
// A listener must never break the write it is told about.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function onChange(listener: Listener): () => void {
|
|
31
|
+
listeners.add(listener)
|
|
32
|
+
return () => void listeners.delete(listener)
|
|
33
|
+
}
|