@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,308 @@
|
|
|
1
|
+
// Everything a host process can ask of the harness, by name. Each method takes one JSON object
|
|
2
|
+
// and returns JSON; the in-process API these call is the same one a Node application uses
|
|
3
|
+
// directly. Methods are grouped by the part of the harness they belong to.
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { basename } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { ensureSharedDir, nativeConfig } from '../accounts/env'
|
|
8
|
+
import { audit, readAudit, summaryAuthentication } from '../accounts/store'
|
|
9
|
+
import { HarnessError, invalid } from '../core/errors'
|
|
10
|
+
import { projectsOrNone } from '../core/projects'
|
|
11
|
+
import * as capabilities from '../capabilities/api'
|
|
12
|
+
import { loadRun as loadCapabilitySnapshot } from '../capabilities/runSnapshot'
|
|
13
|
+
import type { Harness } from '../harness'
|
|
14
|
+
import * as blocks from '../memory/blocks'
|
|
15
|
+
import { validateScopeIds } from '../mcp/connections'
|
|
16
|
+
import type { CallerContext } from '../memory/caller'
|
|
17
|
+
import { dispatch } from '../memory/dispatch'
|
|
18
|
+
import * as facts from '../memory/facts'
|
|
19
|
+
import * as graph from '../memory/graph'
|
|
20
|
+
import * as health from '../memory/health'
|
|
21
|
+
import { listInbox, dismissInboxItem } from '../memory/operations'
|
|
22
|
+
import { writeIndex } from '../memory/proposals'
|
|
23
|
+
import * as recall from '../memory/recall'
|
|
24
|
+
import * as references from '../memory/references'
|
|
25
|
+
import { readRunContext } from '../memory/runContext'
|
|
26
|
+
import * as store from '../memory/store'
|
|
27
|
+
import * as wiki from '../memory/wiki'
|
|
28
|
+
import { reindexQuietly, reindexWiki } from '../memory/wikiIndex'
|
|
29
|
+
import * as plugins from '../plugins/api'
|
|
30
|
+
import * as install from '../plugins/install'
|
|
31
|
+
import { comparison, compile, CONTEXT_PROFILES, load, profiles, render, setProfile, type ContextProfile, type Launch } from '../prompt/compile'
|
|
32
|
+
import { selectHistory } from '../sessions/history'
|
|
33
|
+
import { runsSince } from '../sessions/runIndex'
|
|
34
|
+
import { findRun, listRuns, loadRun, pendingDecisions, readEvents, runFamily, snapshot } from '../sessions/runStore'
|
|
35
|
+
import type { SessionRunInput } from '../sessions/sessions'
|
|
36
|
+
import * as registry from '../skills/registry'
|
|
37
|
+
import type { TerminalPrepareInput } from '../terminal/terminals'
|
|
38
|
+
import type { Handler } from './rpc'
|
|
39
|
+
|
|
40
|
+
type Params = Record<string, any>
|
|
41
|
+
|
|
42
|
+
const text = (params: Params, name: string): string => {
|
|
43
|
+
const value = params[name]
|
|
44
|
+
if (typeof value !== 'string' || !value) throw invalid(`${name} is required.`)
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
const optional = (params: Params, name: string): string | null => (typeof params[name] === 'string' && params[name] ? (params[name] as string) : null)
|
|
48
|
+
const pluginScope = (params: Params): plugins.PluginScope => ({ cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') })
|
|
49
|
+
|
|
50
|
+
/** Sources larger than this are not imported. */
|
|
51
|
+
const MAX_SOURCE_BYTES = 50 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
export function methods(harness: Harness): Record<string, Handler> {
|
|
54
|
+
const { accounts, approvals, apps, runs, sessions, maintenance, terminals, env } = harness
|
|
55
|
+
const root = (): string => env.root
|
|
56
|
+
const wikiDir = (): string => store.wikiDir(env.root)
|
|
57
|
+
const changed = (): void => {
|
|
58
|
+
reindexQuietly(env.root)
|
|
59
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
// ---- The harness itself --------------------------------------------------------------------
|
|
64
|
+
'harness.status': () => ({ dataRoot: harness.dataRoot, productName: env.productName, recovery: harness.recovery, activeRuns: runs.activeRunIds() }),
|
|
65
|
+
'harness.open': (params: Params) => harness.open(text(params, 'dataRoot'), Array.isArray(params.agentIds) ? params.agentIds : []),
|
|
66
|
+
'harness.rebuildIndex': () => harness.rebuildIndex(),
|
|
67
|
+
|
|
68
|
+
// ---- Accounts --------------------------------------------------------------------------------
|
|
69
|
+
'accounts.list': () => accounts.overview(),
|
|
70
|
+
'accounts.find': (params: Params) => accounts.find(text(params, 'accountId')),
|
|
71
|
+
'accounts.resolve': (params: Params) => accounts.resolve(optional(params, 'accountId'), optional(params, 'poolId')),
|
|
72
|
+
'accounts.create': (params: Params) => accounts.create(text(params, 'label'), optional(params, 'authKind') ?? 'subscription'),
|
|
73
|
+
'accounts.rename': (params: Params) => accounts.rename(text(params, 'accountId'), text(params, 'label')),
|
|
74
|
+
'accounts.setDefault': (params: Params) => accounts.setDefault(text(params, 'accountId')),
|
|
75
|
+
'accounts.remove': (params: Params) => accounts.remove(text(params, 'accountId')),
|
|
76
|
+
'accounts.savePool': (params: Params) => accounts.savePool(params.pool),
|
|
77
|
+
'accounts.removePool': (params: Params) => accounts.removePool(text(params, 'poolId')),
|
|
78
|
+
'accounts.audit': (params: Params) => readAudit(accounts.dir, optional(params, 'accountId'), typeof params.limit === 'number' ? params.limit : 100),
|
|
79
|
+
// A line in the account audit log for something the application did with the account (pinning a session to it).
|
|
80
|
+
'accounts.record': (params: Params) => audit(accounts.dir, optional(params, 'accountId'), text(params, 'action'), typeof params.detail === 'string' ? params.detail : ''),
|
|
81
|
+
'accounts.nativeConfig': () => nativeConfig(accounts.dir),
|
|
82
|
+
// The folder every account shares, created if no account has linked it yet.
|
|
83
|
+
'accounts.sharedDir': () => ensureSharedDir(accounts.dir),
|
|
84
|
+
'accounts.refresh': (params: Params) => accounts.refresh(optional(params, 'accountId')),
|
|
85
|
+
'accounts.loginStart': (params: Params) => accounts.loginStart(text(params, 'accountId'), text(params, 'provider'), optional(params, 'method')),
|
|
86
|
+
'accounts.loginRespond': (params: Params) => accounts.loginRespond(text(params, 'loginId'), text(params, 'promptId'), typeof params.value === 'string' ? params.value : null),
|
|
87
|
+
'accounts.loginCancel': (params: Params) => accounts.loginCancel(text(params, 'accountId'), optional(params, 'loginId')),
|
|
88
|
+
'accounts.setApiKey': (params: Params) => accounts.setApiKey(text(params, 'accountId'), text(params, 'key'), text(params, 'provider')),
|
|
89
|
+
'accounts.removeProvider': (params: Params) => accounts.removeProvider(text(params, 'accountId'), text(params, 'provider')),
|
|
90
|
+
'accounts.signOut': (params: Params) => accounts.signOut(text(params, 'accountId')),
|
|
91
|
+
'accounts.providers': () => accounts.providers(),
|
|
92
|
+
'accounts.models': (params: Params) => accounts.models(optional(params, 'accountId')),
|
|
93
|
+
'accounts.authentication': () => summaryAuthentication(accounts.dir),
|
|
94
|
+
'accounts.status': (params: Params) => accounts.status(optional(params, 'accountId')),
|
|
95
|
+
|
|
96
|
+
// ---- Standing approvals ----------------------------------------------------------------------
|
|
97
|
+
'approvals.get': () => approvals.read(),
|
|
98
|
+
'approvals.setAuto': (params: Params) => approvals.setAuto(text(params, 'key'), params.on === true),
|
|
99
|
+
'approvals.remember': (params: Params) => approvals.remember(text(params, 'sessionId'), text(params, 'target')),
|
|
100
|
+
'approvals.forget': (params: Params) => approvals.forget(text(params, 'sessionId'), text(params, 'target')),
|
|
101
|
+
'approvals.forgetSession': (params: Params) => approvals.forgetSession(text(params, 'sessionId')),
|
|
102
|
+
|
|
103
|
+
// ---- Connected apps --------------------------------------------------------------------------
|
|
104
|
+
'connections.list': () => apps.connections.list(),
|
|
105
|
+
'connections.forScope': (params: Params) => apps.connections.forScope({ agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }),
|
|
106
|
+
'connections.save': (params: Params) => apps.save(params.input),
|
|
107
|
+
'connections.allowTool': (params: Params) => apps.connections.allowTool(text(params, 'connectionId'), text(params, 'tool')),
|
|
108
|
+
'connections.remove': (params: Params) => apps.remove(text(params, 'connectionId')),
|
|
109
|
+
'connections.test': (params: Params) => apps.test(text(params, 'connectionId')),
|
|
110
|
+
'connections.signIn': (params: Params) => apps.signIn(text(params, 'connectionId')),
|
|
111
|
+
'connections.signOut': (params: Params) => apps.signOut(text(params, 'connectionId')),
|
|
112
|
+
'connections.runContext': (params: Params) => apps.runContext(text(params, 'runId')),
|
|
113
|
+
'connections.refreshSource': (params: Params) => apps.refreshSource(text(params, 'runId'), text(params, 'traceId'), text(params, 'sourceUri')),
|
|
114
|
+
'connections.setAgentScope': (params: Params) => apps.setAgentScope(params.creator ?? {}, text(params, 'agentId'), Array.isArray(params.ids) ? params.ids : []),
|
|
115
|
+
'connections.removeAgentScope': (params: Params) => apps.removeAgentScope(text(params, 'agentId')),
|
|
116
|
+
'connections.revision': () => apps.connections.revision(),
|
|
117
|
+
'connections.validateScope': (params: Params) => validateScopeIds(apps.connections.list(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }, Array.isArray(params.ids) ? params.ids : []),
|
|
118
|
+
|
|
119
|
+
// ---- Wiki: what a person browses and edits ---------------------------------------------------
|
|
120
|
+
'wiki.pages': (params: Params) => wiki.listPages(root(), optional(params, 'namespace')),
|
|
121
|
+
'wiki.page': (params: Params) => wiki.loadPage(root(), text(params, 'pageId')),
|
|
122
|
+
'wiki.find': (params: Params) => wiki.searchEntries(root(), typeof params.query === 'string' ? params.query : '', params.options ?? {}),
|
|
123
|
+
'wiki.entry': (params: Params) => wiki.loadEntry(root(), text(params, 'entryId')),
|
|
124
|
+
'wiki.backlinks': (params: Params) => wiki.backlinks(root(), text(params, 'entryId')),
|
|
125
|
+
'wiki.namespaces': () => wiki.namespaces(root()),
|
|
126
|
+
'wiki.revisions': (params: Params) => wiki.listRevisions(root(), text(params, 'entryId')),
|
|
127
|
+
'wiki.revision': (params: Params) => wiki.loadRevision(root(), text(params, 'entryId'), text(params, 'revisionId')),
|
|
128
|
+
'wiki.restore': (params: Params) => {
|
|
129
|
+
wiki.restore(root(), text(params, 'entryId'), text(params, 'revisionId'))
|
|
130
|
+
changed()
|
|
131
|
+
},
|
|
132
|
+
// A project was added: it gets its namespace, its fixed pages, and its brief.
|
|
133
|
+
'wiki.ensureProject': (params: Params) => {
|
|
134
|
+
store.ensureProjectNamespace(root(), text(params, 'slug'), text(params, 'name'))
|
|
135
|
+
blocks.ensureProject(wikiDir(), text(params, 'slug'), text(params, 'name'))
|
|
136
|
+
writeIndex(wikiDir())
|
|
137
|
+
changed()
|
|
138
|
+
},
|
|
139
|
+
// Files changed outside the harness (an editor, a sync client): bring the index up to date.
|
|
140
|
+
'wiki.reindex': () => {
|
|
141
|
+
const pages = reindexWiki(root())
|
|
142
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
143
|
+
return pages
|
|
144
|
+
},
|
|
145
|
+
'wiki.sources': () => store.listSources(wikiDir()),
|
|
146
|
+
'wiki.ingest': (params: Params) => {
|
|
147
|
+
const path = text(params, 'path')
|
|
148
|
+
if (statSync(path).size > MAX_SOURCE_BYTES) throw invalid('Sources larger than 50 MB are not imported.')
|
|
149
|
+
const source = maintenance.ingest(optional(params, 'name') ?? basename(path), readFileSync(path), optional(params, 'namespace') ?? store.LIBRARY_NAMESPACE)
|
|
150
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
151
|
+
return source
|
|
152
|
+
},
|
|
153
|
+
'wiki.log': () => wiki.recentLog(root()),
|
|
154
|
+
'wiki.graph': () => graph.build(env),
|
|
155
|
+
'wiki.health': () => health.check(root()),
|
|
156
|
+
'wiki.inbox': () => listInbox(wikiDir()),
|
|
157
|
+
'wiki.inboxDismiss': (params: Params) => {
|
|
158
|
+
dismissInboxItem(wikiDir(), text(params, 'itemId'))
|
|
159
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
160
|
+
},
|
|
161
|
+
'wiki.inboxAccept': async (params: Params) => {
|
|
162
|
+
// An accepted plugin must arrive untrusted: packages that loaded before are taken over first, never together with it.
|
|
163
|
+
await plugins.ensureAdopted(harness)
|
|
164
|
+
const message = maintenance.acceptInboxItem(text(params, 'itemId'))
|
|
165
|
+
harness.bus.emit({ type: 'plugins.changed' })
|
|
166
|
+
return message
|
|
167
|
+
},
|
|
168
|
+
'wiki.undoOperation': (params: Params) => maintenance.undoOperation(text(params, 'operationId')),
|
|
169
|
+
'wiki.runContext': (params: Params) => readRunContext(root(), text(params, 'runId')),
|
|
170
|
+
|
|
171
|
+
// ---- Facts and memory blocks -----------------------------------------------------------------
|
|
172
|
+
'facts.list': () => facts.loadMemories(wikiDir()).map((memory) => facts.response(wikiDir(), memory)),
|
|
173
|
+
'facts.save': (params: Params) => {
|
|
174
|
+
const saved = facts.saveAsUser(wikiDir(), params.input)
|
|
175
|
+
changed()
|
|
176
|
+
return saved
|
|
177
|
+
},
|
|
178
|
+
'blocks.list': (params: Params) => {
|
|
179
|
+
// Defaults exist for the user, each agent the application names, and each project.
|
|
180
|
+
const directory = wikiDir()
|
|
181
|
+
blocks.ensureUser(directory)
|
|
182
|
+
for (const agentId of Array.isArray(params.agentIds) ? params.agentIds : []) blocks.ensureAgent(directory, String(agentId))
|
|
183
|
+
for (const project of projectsOrNone(root())) blocks.ensureProject(directory, project.slug, project.name)
|
|
184
|
+
return blocks.loadAll(directory).map((block) => blocks.response(directory, block))
|
|
185
|
+
},
|
|
186
|
+
'blocks.save': (params: Params) => {
|
|
187
|
+
const saved = blocks.response(wikiDir(), blocks.saveAsUser(wikiDir(), params.input))
|
|
188
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
189
|
+
return saved
|
|
190
|
+
},
|
|
191
|
+
'blocks.undo': (params: Params) => {
|
|
192
|
+
const block = blocks.response(wikiDir(), blocks.undoLast(wikiDir(), text(params, 'blockId')))
|
|
193
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
194
|
+
return block
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// ---- What a session calls, on behalf of a caller the host names -------------------------------
|
|
198
|
+
'memory.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}),
|
|
199
|
+
|
|
200
|
+
// ---- Recall ------------------------------------------------------------------------------------
|
|
201
|
+
'recall.search': (params: Params) => recall.searchAll(env, typeof params.query === 'string' ? params.query : '', params.filter ?? {}),
|
|
202
|
+
'recall.read': (params: Params) => recall.read(root(), text(params, 'runId')),
|
|
203
|
+
|
|
204
|
+
// ---- The compiled prompt -----------------------------------------------------------------------
|
|
205
|
+
'context.compile': (params: Params) => {
|
|
206
|
+
const pack = compile(env, (params.context ?? {}) as CallerContext, (params.launch ?? {}) as Launch)
|
|
207
|
+
return params.render ? { pack, text: render(env, pack) } : pack
|
|
208
|
+
},
|
|
209
|
+
'context.runPack': (params: Params) => load(root(), text(params, 'runId')),
|
|
210
|
+
'context.profiles': () => ({ profiles: profiles(root()), stats: comparison(root()) }),
|
|
211
|
+
'context.setProfile': (params: Params) => {
|
|
212
|
+
const profile = params.profile as ContextProfile
|
|
213
|
+
if (!CONTEXT_PROFILES.includes(profile)) throw invalid('Unknown context profile.')
|
|
214
|
+
setProfile(root(), text(params, 'subject'), profile)
|
|
215
|
+
},
|
|
216
|
+
'references.pin': (params: Params) => references.pinAll(root(), (Array.isArray(params.items) ? params.items : []).map((item: Params) => ({ kind: item.kind === 'skill' ? ('skill' as const) : ('page' as const), id: text(item, 'id') }))),
|
|
217
|
+
'references.body': (params: Params) => references.body(root(), text(params, 'id'), optional(params, 'revision')),
|
|
218
|
+
|
|
219
|
+
// ---- Skills, prompts, rules --------------------------------------------------------------------
|
|
220
|
+
'skills.list': () => registry.state(root()),
|
|
221
|
+
'skills.setEnabled': (params: Params) => {
|
|
222
|
+
const state = registry.setEnabled(root(), optional(params, 'projectId'), Array.isArray(params.ids) ? params.ids : [])
|
|
223
|
+
// The same choices are what the plugin list shows.
|
|
224
|
+
harness.bus.emit({ type: 'plugins.changed' })
|
|
225
|
+
return state
|
|
226
|
+
},
|
|
227
|
+
'skills.create': (params: Params) => registry.create(root(), text(params, 'kind'), text(params, 'name')),
|
|
228
|
+
'skills.import': (params: Params) => registry.importSkills(root(), Array.isArray(params.skills) ? params.skills : []),
|
|
229
|
+
'skills.enabledFor': (params: Params) => registry.enabledFor(root(), optional(params, 'projectId')),
|
|
230
|
+
|
|
231
|
+
// ---- Plugins: everything that shapes a session, and the trust a working folder's code needs ---
|
|
232
|
+
// A host that is not a Node program describes its own parts here instead of in the options.
|
|
233
|
+
'plugins.describe': (params: Params) => {
|
|
234
|
+
harness.describe(Array.isArray(params.plugins) ? params.plugins : [])
|
|
235
|
+
harness.bus.emit({ type: 'plugins.changed' })
|
|
236
|
+
return { described: [...harness.applicationPlugins] }
|
|
237
|
+
},
|
|
238
|
+
'plugins.list': (params: Params) => plugins.list(harness, pluginScope(params)),
|
|
239
|
+
'plugins.get': async (params: Params) => (await plugins.list(harness, pluginScope(params))).find((plugin) => plugin.id === text(params, 'pluginId')) ?? null,
|
|
240
|
+
'plugins.attach': (params: Params) => plugins.attach(harness, { ...pluginScope(params), id: text(params, 'id'), scope: params.scope ?? null }, true),
|
|
241
|
+
'plugins.detach': (params: Params) => plugins.attach(harness, { ...pluginScope(params), id: text(params, 'id'), scope: params.scope ?? null }, false),
|
|
242
|
+
'plugins.install': (params: Params) => install.install(harness, { ...pluginScope(params), source: text(params, 'source') }),
|
|
243
|
+
'plugins.remove': (params: Params) => install.remove(harness, { ...pluginScope(params), pluginId: text(params, 'pluginId') }),
|
|
244
|
+
'plugins.update': (params: Params) => install.update(harness, { ...pluginScope(params), pluginId: optional(params, 'pluginId') }),
|
|
245
|
+
'plugins.configure': (params: Params) => plugins.configure(harness, { ...pluginScope(params), pluginId: text(params, 'pluginId'), settings: (params.settings && typeof params.settings === 'object' ? params.settings : {}) as Record<string, unknown> }),
|
|
246
|
+
'plugins.forgetAgent': (params: Params) => plugins.forgetAgent(harness, text(params, 'agentId')),
|
|
247
|
+
'plugins.inventory': (params: Params) => plugins.inventoryFor(harness, { ...pluginScope(params), context: (params.context ?? {}) as CallerContext, launch: (params.launch ?? {}) as Launch, tools: params.tools, connections: params.connections }),
|
|
248
|
+
'plugins.trust': (params: Params) => plugins.trust(harness, { cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), hash: text(params, 'hash') }),
|
|
249
|
+
'plugins.untrust': (params: Params) => plugins.untrust(harness, { cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), hash: text(params, 'hash') }),
|
|
250
|
+
|
|
251
|
+
// ---- Capabilities: the caller-facing view over the existing stores -------------------------
|
|
252
|
+
'capabilities.list': (params: Params) => capabilities.list(harness, pluginScope(params)),
|
|
253
|
+
'capabilities.inventory': (params: Params) => capabilities.inventory(harness, { ...pluginScope(params), context: (params.context ?? {}) as CallerContext, launch: (params.launch ?? {}) as Launch, tools: Array.isArray(params.tools) ? params.tools : [], connections: params.connections }),
|
|
254
|
+
'capabilities.attach': (params: Params) => capabilities.switchCapability(harness, { ...pluginScope(params), id: text(params, 'id'), scope: params.scope ?? null }, true),
|
|
255
|
+
'capabilities.detach': (params: Params) => capabilities.switchCapability(harness, { ...pluginScope(params), id: text(params, 'id'), scope: params.scope ?? null }, false),
|
|
256
|
+
'capabilities.resolve': (params: Params) => capabilities.resolve(harness, { ...pluginScope(params), tools: Array.isArray(params.tools) ? params.tools.filter((item: unknown): item is string => typeof item === 'string') : [] }),
|
|
257
|
+
'capabilities.run': (params: Params) => loadCapabilitySnapshot(root(), text(params, 'runId')),
|
|
258
|
+
|
|
259
|
+
// ---- Runs --------------------------------------------------------------------------------------
|
|
260
|
+
'runs.load': (params: Params) => snapshot(root(), text(params, 'runId')),
|
|
261
|
+
'runs.family': (params: Params) => runFamily(root(), text(params, 'runId')),
|
|
262
|
+
'runs.find': (params: Params) => findRun(root(), text(params, 'runId')),
|
|
263
|
+
'runs.list': (params: Params) => listRuns(root(), params.filter ?? {}),
|
|
264
|
+
// Ids of the runs one session, agent, or account made since an instant, oldest first.
|
|
265
|
+
'runs.since': (params: Params) => runsSince(root(), { session: optional(params.filter ?? {}, 'session'), agent: optional(params.filter ?? {}, 'agent'), account: optional(params.filter ?? {}, 'account') }, optional(params, 'since')),
|
|
266
|
+
'runs.events': (params: Params) => readEvents(root(), text(params, 'runId')),
|
|
267
|
+
'runs.pending': (params: Params) => pendingDecisions(readEvents(root(), text(params, 'runId'))),
|
|
268
|
+
'runs.active': () => runs.activeRunIds(),
|
|
269
|
+
'runs.interrupt': async (params: Params) => {
|
|
270
|
+
const runId = text(params, 'runId')
|
|
271
|
+
loadRun(root(), runId)
|
|
272
|
+
await runs.interrupt(runId)
|
|
273
|
+
return loadRun(root(), runId)
|
|
274
|
+
},
|
|
275
|
+
// What the application reports into a live run, and an approval it asks a person for.
|
|
276
|
+
'runs.emit': (params: Params) => {
|
|
277
|
+
const run = runs.active(text(params, 'runId'))
|
|
278
|
+
if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
|
|
279
|
+
run.emit(params.type, params.payload ?? {})
|
|
280
|
+
},
|
|
281
|
+
'runs.requestApproval': async (params: Params) => {
|
|
282
|
+
const run = runs.active(text(params, 'runId'))
|
|
283
|
+
if (!run) throw new HarnessError('run_not_active', 'The requesting run has ended.')
|
|
284
|
+
const decision = await run.requestApproval({
|
|
285
|
+
target: text(params, 'target'),
|
|
286
|
+
action: typeof params.action === 'string' ? params.action : '',
|
|
287
|
+
...(typeof params.screenshotRef === 'string' ? { screenshotRef: params.screenshotRef } : {}),
|
|
288
|
+
...(params.operation ? { operation: params.operation } : {}),
|
|
289
|
+
standing: params.standing === 'none' || params.standing === 'switch' ? params.standing : 'full',
|
|
290
|
+
...(typeof params.timeoutMs === 'number' ? { timeoutMs: params.timeoutMs } : {})
|
|
291
|
+
})
|
|
292
|
+
return { decision }
|
|
293
|
+
},
|
|
294
|
+
'runs.resolveApproval': (params: Params) => runs.resolveApproval(text(params, 'runId'), text(params, 'approvalId'), params.decision),
|
|
295
|
+
'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'), { selected: Array.isArray(params.answer?.selected) ? params.answer.selected : [], ...(typeof params.answer?.text === 'string' && params.answer.text ? { text: params.answer.text } : {}) }),
|
|
296
|
+
|
|
297
|
+
// ---- Sessions ----------------------------------------------------------------------------------
|
|
298
|
+
'sessions.run': (params: Params) => sessions.run(params as SessionRunInput),
|
|
299
|
+
'sessions.history': (params: Params) => ({ sessionFile: selectHistory({ sessionDir: text(params, 'sessionDir'), resume: params.resume ?? null }) }),
|
|
300
|
+
|
|
301
|
+
// ---- Terminal sessions -------------------------------------------------------------------------
|
|
302
|
+
'terminal.prepare': (params: Params) => terminals.prepare(params as TerminalPrepareInput),
|
|
303
|
+
'terminal.send': (params: Params) => terminals.send({ sessionId: text(params, 'sessionId'), runId: optional(params, 'runId'), text: text(params, 'text') }),
|
|
304
|
+
'terminal.input': (params: Params) => terminals.input(text(params, 'sessionId'), typeof params.data === 'string' ? params.data : ''),
|
|
305
|
+
'terminal.state': (params: Params) => terminals.state(text(params, 'sessionId')),
|
|
306
|
+
'terminal.dispose': (params: Params) => terminals.dispose(text(params, 'sessionId'))
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What a host that is not a Node program supplies over the protocol instead of in-process:
|
|
2
|
+
// its secret store, its policy, its names for agents, and its own tools. Each is a call to the
|
|
3
|
+
// host; the harness works without any of them.
|
|
4
|
+
import type { SecretStore } from '../accounts/secrets'
|
|
5
|
+
import type { Directory } from '../core/env'
|
|
6
|
+
import type { ToolPolicy } from '../sessions/policy'
|
|
7
|
+
import type { ToolProvider, ToolSpec } from '../tools/types'
|
|
8
|
+
import type { RpcPeer } from './rpc'
|
|
9
|
+
|
|
10
|
+
/** `secrets.get`, `secrets.set`, `secrets.delete` on the host. */
|
|
11
|
+
export function remoteSecrets(peer: RpcPeer): SecretStore {
|
|
12
|
+
return {
|
|
13
|
+
get: (key) => peer.callHost<string | null>('secrets.get', { key }),
|
|
14
|
+
set: async (key, value) => void (await peer.callHost('secrets.set', { key, value })),
|
|
15
|
+
delete: async (key) => void (await peer.callHost('secrets.delete', { key }))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `policy.scope`, `policy.authorize`, `policy.approval`, `policy.connections` on the host. */
|
|
20
|
+
export function remotePolicy(peer: RpcPeer): ToolPolicy {
|
|
21
|
+
const identify = (call: { run: { id: string; sessionId: string }; caller: unknown; tool: string; args: unknown }) => ({ runId: call.run.id, sessionId: call.run.sessionId, caller: call.caller, tool: call.tool, args: call.args })
|
|
22
|
+
return {
|
|
23
|
+
scope: (call) => peer.callHost('policy.scope', identify(call), 90_000),
|
|
24
|
+
authorize: async (call) => void (await peer.callHost('policy.authorize', identify(call), 90_000)),
|
|
25
|
+
approval: (input) => peer.callHost('policy.approval', { runId: input.run.id, sessionId: input.run.sessionId, target: input.target, operation: input.operation ?? null }),
|
|
26
|
+
connections: (input) => peer.callHost('policy.connections', input)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Names the host knows and the harness shows in what it writes. The host pushes them with the
|
|
32
|
+
* `directory.set` method, because the lookups are synchronous.
|
|
33
|
+
*/
|
|
34
|
+
export function remoteDirectory(peer: RpcPeer): Directory {
|
|
35
|
+
let agents: Record<string, string> = {}
|
|
36
|
+
let sessions: Record<string, string> = {}
|
|
37
|
+
peer.handle('directory.set', (params: { agents?: Record<string, string>; sessions?: Record<string, string> }) => {
|
|
38
|
+
if (params.agents) agents = params.agents
|
|
39
|
+
if (params.sessions) sessions = { ...sessions, ...params.sessions }
|
|
40
|
+
})
|
|
41
|
+
return { agentName: (id) => agents[id] ?? null, sessionAgent: (id) => sessions[id] ?? null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface HostTool {
|
|
45
|
+
name: string
|
|
46
|
+
label?: string
|
|
47
|
+
description: string
|
|
48
|
+
/** JSON Schema of the tool's input. */
|
|
49
|
+
parameters: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tools the host registers with `tools.register` and runs when the harness calls `tools.call`
|
|
54
|
+
* back. A session is given those its `allow` list names.
|
|
55
|
+
*/
|
|
56
|
+
export function remoteTools(peer: RpcPeer): ToolProvider {
|
|
57
|
+
let registered: HostTool[] = []
|
|
58
|
+
peer.handle('tools.register', (params: { tools?: HostTool[] }) => {
|
|
59
|
+
registered = Array.isArray(params.tools) ? params.tools : []
|
|
60
|
+
return { registered: registered.map((tool) => tool.name) }
|
|
61
|
+
})
|
|
62
|
+
return (session) =>
|
|
63
|
+
registered
|
|
64
|
+
.filter((tool) => session.allow.includes(tool.name))
|
|
65
|
+
.map(
|
|
66
|
+
(tool): ToolSpec => ({
|
|
67
|
+
name: tool.name,
|
|
68
|
+
label: tool.label ?? tool.name,
|
|
69
|
+
description: tool.description,
|
|
70
|
+
parameters: (Type) => Type.Unsafe(tool.parameters),
|
|
71
|
+
execute: async (input) => {
|
|
72
|
+
const result = await peer.callHost<unknown>('tools.call', { name: tool.name, input, runId: session.run.runId, sessionId: session.run.sessionId, caller: session.caller, depth: session.depth, cwd: session.cwd }, 1_230_000)
|
|
73
|
+
return typeof result === 'string' ? result : JSON.stringify(result)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Newline-delimited JSON between the harness and a host process that embeds it.
|
|
2
|
+
//
|
|
3
|
+
// host → harness { v, id, method, params } request
|
|
4
|
+
// harness → host { v, id, result } | { v, id, error }
|
|
5
|
+
// harness → host { v, event } run event (validated before sending)
|
|
6
|
+
// harness → host { v, notice } some state changed and should be read again
|
|
7
|
+
// harness → host { v, id, call, params } the harness asks the host for a named operation
|
|
8
|
+
// host → harness { v, id, result } | { v, id, error } reply to a call
|
|
9
|
+
//
|
|
10
|
+
// stdout carries only protocol lines; diagnostics go to stderr.
|
|
11
|
+
import { createInterface } from 'node:readline'
|
|
12
|
+
|
|
13
|
+
import { log } from '../core/bus'
|
|
14
|
+
import { HarnessError, toErrorShape, type ErrorShape } from '../core/errors'
|
|
15
|
+
|
|
16
|
+
export const PROTOCOL_VERSION = 1
|
|
17
|
+
|
|
18
|
+
export type Handler = (params: any) => Promise<unknown> | unknown
|
|
19
|
+
|
|
20
|
+
export interface Transport {
|
|
21
|
+
write(line: string): void
|
|
22
|
+
onLine(listener: (line: string) => void): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stdioTransport(): Transport {
|
|
26
|
+
const reader = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
27
|
+
return {
|
|
28
|
+
write: (line) => void process.stdout.write(`${line}\n`),
|
|
29
|
+
onLine: (listener) => void reader.on('line', listener)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class RpcPeer {
|
|
34
|
+
private readonly handlers = new Map<string, Handler>()
|
|
35
|
+
private readonly pendingCalls = new Map<string, { resolve: (value: unknown) => void; reject: (error: HarnessError) => void }>()
|
|
36
|
+
private nextCallId = 0
|
|
37
|
+
|
|
38
|
+
constructor(private readonly transport: Transport) {
|
|
39
|
+
transport.onLine((line) => void this.receive(line))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
handle(method: string, handler: Handler): void {
|
|
43
|
+
this.handlers.set(method, handler)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
methods(): string[] {
|
|
47
|
+
return [...this.handlers.keys()].sort()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
send(message: Record<string, unknown>): void {
|
|
51
|
+
this.transport.write(JSON.stringify({ v: PROTOCOL_VERSION, ...message }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Asks the host to perform a named operation. */
|
|
55
|
+
callHost<T = unknown>(call: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
56
|
+
const id = `s${++this.nextCallId}`
|
|
57
|
+
return new Promise<T>((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.pendingCalls.delete(id)
|
|
60
|
+
reject(new HarnessError('host_timeout', `The host did not answer ${call}.`))
|
|
61
|
+
}, timeoutMs)
|
|
62
|
+
this.pendingCalls.set(id, {
|
|
63
|
+
resolve: (value) => {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
resolve(value as T)
|
|
66
|
+
},
|
|
67
|
+
reject: (error) => {
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
reject(error)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
this.send({ id, call, params })
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async receive(line: string): Promise<void> {
|
|
77
|
+
let message: Record<string, unknown>
|
|
78
|
+
try {
|
|
79
|
+
message = JSON.parse(line) as Record<string, unknown>
|
|
80
|
+
} catch {
|
|
81
|
+
log('ignored a non-JSON line')
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (message.v !== PROTOCOL_VERSION || typeof message.id !== 'string') {
|
|
85
|
+
log('ignored a message with an unknown version or no id')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const id = message.id
|
|
89
|
+
|
|
90
|
+
if (typeof message.method === 'string') {
|
|
91
|
+
const handler = this.handlers.get(message.method)
|
|
92
|
+
if (!handler) {
|
|
93
|
+
this.send({ id, error: { code: 'unknown_method', message: `Unknown method ${message.method}.` } })
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = await handler(message.params ?? {})
|
|
98
|
+
this.send({ id, result: result ?? null })
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.send({ id, error: toErrorShape(error) })
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pending = this.pendingCalls.get(id)
|
|
106
|
+
if (pending) {
|
|
107
|
+
this.pendingCalls.delete(id)
|
|
108
|
+
if (message.error) {
|
|
109
|
+
const shape = message.error as ErrorShape
|
|
110
|
+
pending.reject(new HarnessError(shape.code, shape.message, shape.retryHint ?? null))
|
|
111
|
+
} else {
|
|
112
|
+
pending.resolve(message.result)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Serves a harness to a host process over a transport: every method in the table, run events
|
|
2
|
+
// as they are recorded, and notices when state changes.
|
|
3
|
+
import type { Harness } from '../harness'
|
|
4
|
+
import { methods } from './methods'
|
|
5
|
+
import { RpcPeer, type Transport } from './rpc'
|
|
6
|
+
|
|
7
|
+
export interface Served {
|
|
8
|
+
peer: RpcPeer
|
|
9
|
+
/** Stops live work and releases what the harness holds. */
|
|
10
|
+
shutdown(): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Registers the method table on `peer` and forwards events and notices to it. */
|
|
14
|
+
export function attach(harness: Harness, peer: RpcPeer): void {
|
|
15
|
+
for (const [name, handler] of Object.entries(methods(harness))) peer.handle(name, handler)
|
|
16
|
+
peer.handle('harness.methods', () => peer.methods())
|
|
17
|
+
harness.runs.listen((event) => peer.send({ event }))
|
|
18
|
+
harness.bus.on((notice) => peer.send({ notice }))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serve(harness: Harness, transport: Transport): Served {
|
|
22
|
+
const peer = new RpcPeer(transport)
|
|
23
|
+
attach(harness, peer)
|
|
24
|
+
let closing: Promise<void> | null = null
|
|
25
|
+
const shutdown = (): Promise<void> => (closing ??= harness.close())
|
|
26
|
+
peer.handle('harness.shutdown', () => shutdown())
|
|
27
|
+
return { peer, shutdown }
|
|
28
|
+
}
|