@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,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
+ }