@oharness/engine 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 (256) hide show
  1. package/dist/agent/agent.d.ts +196 -0
  2. package/dist/agent/agent.js +758 -0
  3. package/dist/agent/agent.js.map +1 -0
  4. package/dist/agent/compaction.d.ts +68 -0
  5. package/dist/agent/compaction.js +179 -0
  6. package/dist/agent/compaction.js.map +1 -0
  7. package/dist/agent/hooks.d.ts +77 -0
  8. package/dist/agent/hooks.js +152 -0
  9. package/dist/agent/hooks.js.map +1 -0
  10. package/dist/agent/memory.d.ts +45 -0
  11. package/dist/agent/memory.js +147 -0
  12. package/dist/agent/memory.js.map +1 -0
  13. package/dist/agent/overflow.d.ts +35 -0
  14. package/dist/agent/overflow.js +69 -0
  15. package/dist/agent/overflow.js.map +1 -0
  16. package/dist/agent/permissions.d.ts +181 -0
  17. package/dist/agent/permissions.js +289 -0
  18. package/dist/agent/permissions.js.map +1 -0
  19. package/dist/agent/prompt.d.ts +25 -0
  20. package/dist/agent/prompt.js +63 -0
  21. package/dist/agent/prompt.js.map +1 -0
  22. package/dist/agent/session.d.ts +108 -0
  23. package/dist/agent/session.js +231 -0
  24. package/dist/agent/session.js.map +1 -0
  25. package/dist/agent/settings.d.ts +37 -0
  26. package/dist/agent/settings.js +113 -0
  27. package/dist/agent/settings.js.map +1 -0
  28. package/dist/agent/skills.d.ts +58 -0
  29. package/dist/agent/skills.js +225 -0
  30. package/dist/agent/skills.js.map +1 -0
  31. package/dist/agent/store.d.ts +308 -0
  32. package/dist/agent/store.js +596 -0
  33. package/dist/agent/store.js.map +1 -0
  34. package/dist/agent/subagent.d.ts +44 -0
  35. package/dist/agent/subagent.js +138 -0
  36. package/dist/agent/subagent.js.map +1 -0
  37. package/dist/agent/titles.d.ts +8 -0
  38. package/dist/agent/titles.js +23 -0
  39. package/dist/agent/titles.js.map +1 -0
  40. package/dist/agent/workspace.d.ts +60 -0
  41. package/dist/agent/workspace.js +156 -0
  42. package/dist/agent/workspace.js.map +1 -0
  43. package/dist/artifacts/publish.d.ts +94 -0
  44. package/dist/artifacts/publish.js +104 -0
  45. package/dist/artifacts/publish.js.map +1 -0
  46. package/dist/artifacts/store.d.ts +324 -0
  47. package/dist/artifacts/store.js +500 -0
  48. package/dist/artifacts/store.js.map +1 -0
  49. package/dist/auth/index.d.ts +13 -0
  50. package/dist/auth/index.js +10 -0
  51. package/dist/auth/index.js.map +1 -0
  52. package/dist/auth/manager.d.ts +83 -0
  53. package/dist/auth/manager.js +266 -0
  54. package/dist/auth/manager.js.map +1 -0
  55. package/dist/auth/oauth.d.ts +83 -0
  56. package/dist/auth/oauth.js +327 -0
  57. package/dist/auth/oauth.js.map +1 -0
  58. package/dist/auth/providers/account.d.ts +59 -0
  59. package/dist/auth/providers/account.js +282 -0
  60. package/dist/auth/providers/account.js.map +1 -0
  61. package/dist/auth/providers/anthropic.d.ts +24 -0
  62. package/dist/auth/providers/anthropic.js +209 -0
  63. package/dist/auth/providers/anthropic.js.map +1 -0
  64. package/dist/auth/providers/generic.d.ts +57 -0
  65. package/dist/auth/providers/generic.js +155 -0
  66. package/dist/auth/providers/generic.js.map +1 -0
  67. package/dist/auth/providers/official.d.ts +3 -0
  68. package/dist/auth/providers/official.js +90 -0
  69. package/dist/auth/providers/official.js.map +1 -0
  70. package/dist/auth/providers/ottoport.d.ts +3 -0
  71. package/dist/auth/providers/ottoport.js +60 -0
  72. package/dist/auth/providers/ottoport.js.map +1 -0
  73. package/dist/auth/store.d.ts +22 -0
  74. package/dist/auth/store.js +138 -0
  75. package/dist/auth/store.js.map +1 -0
  76. package/dist/auth/types.d.ts +167 -0
  77. package/dist/auth/types.js +16 -0
  78. package/dist/auth/types.js.map +1 -0
  79. package/dist/config.d.ts +197 -0
  80. package/dist/config.js +195 -0
  81. package/dist/config.js.map +1 -0
  82. package/dist/core/errors.d.ts +46 -0
  83. package/dist/core/errors.js +83 -0
  84. package/dist/core/errors.js.map +1 -0
  85. package/dist/core/tokens.d.ts +12 -0
  86. package/dist/core/tokens.js +56 -0
  87. package/dist/core/tokens.js.map +1 -0
  88. package/dist/core/types.d.ts +91 -0
  89. package/dist/core/types.js +49 -0
  90. package/dist/core/types.js.map +1 -0
  91. package/dist/harness.d.ts +302 -0
  92. package/dist/harness.js +695 -0
  93. package/dist/harness.js.map +1 -0
  94. package/dist/index.d.ts +32 -0
  95. package/dist/index.js +21 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/mcp/client.d.ts +55 -0
  98. package/dist/mcp/client.js +199 -0
  99. package/dist/mcp/client.js.map +1 -0
  100. package/dist/mcp/index.d.ts +13 -0
  101. package/dist/mcp/index.js +8 -0
  102. package/dist/mcp/index.js.map +1 -0
  103. package/dist/mcp/manager.d.ts +77 -0
  104. package/dist/mcp/manager.js +325 -0
  105. package/dist/mcp/manager.js.map +1 -0
  106. package/dist/mcp/oauth.d.ts +63 -0
  107. package/dist/mcp/oauth.js +302 -0
  108. package/dist/mcp/oauth.js.map +1 -0
  109. package/dist/mcp/protocol.d.ts +130 -0
  110. package/dist/mcp/protocol.js +14 -0
  111. package/dist/mcp/protocol.js.map +1 -0
  112. package/dist/mcp/registry.d.ts +52 -0
  113. package/dist/mcp/registry.js +100 -0
  114. package/dist/mcp/registry.js.map +1 -0
  115. package/dist/mcp/registry.json +293 -0
  116. package/dist/mcp/secrets.d.ts +20 -0
  117. package/dist/mcp/secrets.js +75 -0
  118. package/dist/mcp/secrets.js.map +1 -0
  119. package/dist/mcp/transport.d.ts +51 -0
  120. package/dist/mcp/transport.js +158 -0
  121. package/dist/mcp/transport.js.map +1 -0
  122. package/dist/paths.d.ts +37 -0
  123. package/dist/paths.js +63 -0
  124. package/dist/paths.js.map +1 -0
  125. package/dist/plugins.d.ts +55 -0
  126. package/dist/plugins.js +201 -0
  127. package/dist/plugins.js.map +1 -0
  128. package/dist/providers/builtin.d.ts +23 -0
  129. package/dist/providers/builtin.js +141 -0
  130. package/dist/providers/builtin.js.map +1 -0
  131. package/dist/providers/catalog.d.ts +129 -0
  132. package/dist/providers/catalog.js +254 -0
  133. package/dist/providers/catalog.js.map +1 -0
  134. package/dist/providers/catalog.json +472 -0
  135. package/dist/providers/discovery.d.ts +16 -0
  136. package/dist/providers/discovery.js +161 -0
  137. package/dist/providers/discovery.js.map +1 -0
  138. package/dist/providers/http.d.ts +24 -0
  139. package/dist/providers/http.js +139 -0
  140. package/dist/providers/http.js.map +1 -0
  141. package/dist/providers/index.d.ts +10 -0
  142. package/dist/providers/index.js +8 -0
  143. package/dist/providers/index.js.map +1 -0
  144. package/dist/providers/piai.d.ts +124 -0
  145. package/dist/providers/piai.js +551 -0
  146. package/dist/providers/piai.js.map +1 -0
  147. package/dist/providers/registry.d.ts +65 -0
  148. package/dist/providers/registry.js +195 -0
  149. package/dist/providers/registry.js.map +1 -0
  150. package/dist/providers/resilient.d.ts +46 -0
  151. package/dist/providers/resilient.js +117 -0
  152. package/dist/providers/resilient.js.map +1 -0
  153. package/dist/providers/types.d.ts +182 -0
  154. package/dist/providers/types.js +50 -0
  155. package/dist/providers/types.js.map +1 -0
  156. package/dist/schedule/cron.d.ts +35 -0
  157. package/dist/schedule/cron.js +125 -0
  158. package/dist/schedule/cron.js.map +1 -0
  159. package/dist/schedule/scheduler.d.ts +87 -0
  160. package/dist/schedule/scheduler.js +237 -0
  161. package/dist/schedule/scheduler.js.map +1 -0
  162. package/dist/storage/kv.d.ts +56 -0
  163. package/dist/storage/kv.js +173 -0
  164. package/dist/storage/kv.js.map +1 -0
  165. package/dist/storage/migrate.d.ts +11 -0
  166. package/dist/storage/migrate.js +93 -0
  167. package/dist/storage/migrate.js.map +1 -0
  168. package/dist/storage/project.d.ts +9 -0
  169. package/dist/storage/project.js +57 -0
  170. package/dist/storage/project.js.map +1 -0
  171. package/dist/storage/records.d.ts +32 -0
  172. package/dist/storage/records.js +48 -0
  173. package/dist/storage/records.js.map +1 -0
  174. package/dist/sync/client.d.ts +79 -0
  175. package/dist/sync/client.js +118 -0
  176. package/dist/sync/client.js.map +1 -0
  177. package/dist/sync/mirror.d.ts +85 -0
  178. package/dist/sync/mirror.js +389 -0
  179. package/dist/sync/mirror.js.map +1 -0
  180. package/dist/sync/protocol.d.ts +67 -0
  181. package/dist/sync/protocol.js +16 -0
  182. package/dist/sync/protocol.js.map +1 -0
  183. package/dist/sync/settings.d.ts +56 -0
  184. package/dist/sync/settings.js +60 -0
  185. package/dist/sync/settings.js.map +1 -0
  186. package/dist/tools/builtin/artifact.d.ts +16 -0
  187. package/dist/tools/builtin/artifact.js +134 -0
  188. package/dist/tools/builtin/artifact.js.map +1 -0
  189. package/dist/tools/builtin/bash.d.ts +3 -0
  190. package/dist/tools/builtin/bash.js +142 -0
  191. package/dist/tools/builtin/bash.js.map +1 -0
  192. package/dist/tools/builtin/fs.d.ts +19 -0
  193. package/dist/tools/builtin/fs.js +280 -0
  194. package/dist/tools/builtin/fs.js.map +1 -0
  195. package/dist/tools/builtin/index.d.ts +22 -0
  196. package/dist/tools/builtin/index.js +42 -0
  197. package/dist/tools/builtin/index.js.map +1 -0
  198. package/dist/tools/builtin/mcp-resource.d.ts +3 -0
  199. package/dist/tools/builtin/mcp-resource.js +220 -0
  200. package/dist/tools/builtin/mcp-resource.js.map +1 -0
  201. package/dist/tools/builtin/memory.d.ts +10 -0
  202. package/dist/tools/builtin/memory.js +76 -0
  203. package/dist/tools/builtin/memory.js.map +1 -0
  204. package/dist/tools/builtin/recall.d.ts +13 -0
  205. package/dist/tools/builtin/recall.js +163 -0
  206. package/dist/tools/builtin/recall.js.map +1 -0
  207. package/dist/tools/builtin/search.d.ts +3 -0
  208. package/dist/tools/builtin/search.js +240 -0
  209. package/dist/tools/builtin/search.js.map +1 -0
  210. package/dist/tools/builtin/skill.d.ts +10 -0
  211. package/dist/tools/builtin/skill.js +46 -0
  212. package/dist/tools/builtin/skill.js.map +1 -0
  213. package/dist/tools/builtin/task.d.ts +9 -0
  214. package/dist/tools/builtin/task.js +68 -0
  215. package/dist/tools/builtin/task.js.map +1 -0
  216. package/dist/tools/builtin/todo.d.ts +20 -0
  217. package/dist/tools/builtin/todo.js +63 -0
  218. package/dist/tools/builtin/todo.js.map +1 -0
  219. package/dist/tools/define.d.ts +19 -0
  220. package/dist/tools/define.js +39 -0
  221. package/dist/tools/define.js.map +1 -0
  222. package/dist/tools/index.d.ts +7 -0
  223. package/dist/tools/index.js +5 -0
  224. package/dist/tools/index.js.map +1 -0
  225. package/dist/tools/registry.d.ts +39 -0
  226. package/dist/tools/registry.js +101 -0
  227. package/dist/tools/registry.js.map +1 -0
  228. package/dist/tools/types.d.ts +112 -0
  229. package/dist/tools/types.js +22 -0
  230. package/dist/tools/types.js.map +1 -0
  231. package/dist/util/id.d.ts +9 -0
  232. package/dist/util/id.js +19 -0
  233. package/dist/util/id.js.map +1 -0
  234. package/dist/util/json.d.ts +25 -0
  235. package/dist/util/json.js +100 -0
  236. package/dist/util/json.js.map +1 -0
  237. package/dist/util/jwt.d.ts +74 -0
  238. package/dist/util/jwt.js +177 -0
  239. package/dist/util/jwt.js.map +1 -0
  240. package/dist/util/logger.d.ts +13 -0
  241. package/dist/util/logger.js +59 -0
  242. package/dist/util/logger.js.map +1 -0
  243. package/dist/util/queue.d.ts +26 -0
  244. package/dist/util/queue.js +106 -0
  245. package/dist/util/queue.js.map +1 -0
  246. package/dist/util/schema.d.ts +17 -0
  247. package/dist/util/schema.js +98 -0
  248. package/dist/util/schema.js.map +1 -0
  249. package/dist/util/shell.d.ts +20 -0
  250. package/dist/util/shell.js +24 -0
  251. package/dist/util/shell.js.map +1 -0
  252. package/official.json +4 -0
  253. package/package.json +38 -0
  254. package/skills/artifact-authoring/SKILL.md +81 -0
  255. package/skills/canvas/SKILL.md +85 -0
  256. package/skills/skill-authoring/SKILL.md +85 -0
@@ -0,0 +1,695 @@
1
+ import { AuthManager } from "./auth/manager.js";
2
+ import { ConfigError } from "./core/errors.js";
3
+ import { apiKeyProvider, oauthProvider } from "./auth/providers/generic.js";
4
+ import { Agent } from "./agent/agent.js";
5
+ import { HookRegistry } from "./agent/hooks.js";
6
+ import { Session } from "./agent/session.js";
7
+ import { SessionStore } from "./agent/store.js";
8
+ import { BUILTIN_SUBAGENTS, SubagentRegistry, loadMarkdownSubagents } from "./agent/subagent.js";
9
+ import { SkillRegistry } from "./agent/skills.js";
10
+ import { SettingsStore } from "./agent/settings.js";
11
+ import { createPolicy } from "./agent/workspace.js";
12
+ import { MemoryStore } from "./agent/memory.js";
13
+ import { loadConfig, loadProjectConfig, loadProjectInstructions, mergeConfig } from "./config.js";
14
+ import { discoverPlugins, pluginContributions } from "./plugins.js";
15
+ import { McpManager } from "./mcp/manager.js";
16
+ import { beginMcpLogin, forgetMcpAuth } from "./mcp/oauth.js";
17
+ import { expandSecrets } from "./mcp/secrets.js";
18
+ import { Scheduler } from "./schedule/scheduler.js";
19
+ import { SyncClient } from "./sync/client.js";
20
+ import { SessionMirror } from "./sync/mirror.js";
21
+ import { isExcluded, resolveSync } from "./sync/settings.js";
22
+ import { projectId } from "./storage/project.js";
23
+ import { updateUserConfig } from "./config.js";
24
+ import { ProviderRegistry } from "./providers/registry.js";
25
+ import { loadCatalog, userCatalogPath } from "./providers/catalog.js";
26
+ import { builtinTools } from "./tools/builtin/index.js";
27
+ import { ToolRegistry } from "./tools/registry.js";
28
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
29
+ import path from "node:path";
30
+ import { createLogger } from "./util/logger.js";
31
+ /**
32
+ * Top-level facade: owns providers, tools, MCP connections, hooks and
33
+ * subagents, and hands out `Agent` instances bound to a `Session`.
34
+ *
35
+ * Construct it with `Harness.create()` rather than `new`, because MCP
36
+ * connection and config loading are async.
37
+ */
38
+ export class Harness {
39
+ cwd;
40
+ config;
41
+ providers;
42
+ tools;
43
+ hooks;
44
+ subagents;
45
+ skills;
46
+ memory;
47
+ settings;
48
+ workspace;
49
+ mcp;
50
+ store;
51
+ auth;
52
+ catalog;
53
+ scheduler;
54
+ ephemeral;
55
+ /** Mirrors this project's transcripts, when sync is on and signed in. */
56
+ mirror;
57
+ syncState = {
58
+ on: false,
59
+ reason: "no-credential",
60
+ };
61
+ /** Active declarative plugin bundles, in their effective override order. */
62
+ plugins;
63
+ logger;
64
+ options;
65
+ projectInstructions;
66
+ constructor(opts, config, catalog, plugins = []) {
67
+ this.options = opts;
68
+ this.cwd = opts.cwd ?? process.cwd();
69
+ this.config = config;
70
+ this.logger = opts.logger ?? createLogger("harness", config.logLevel ?? "warn", opts.logSink);
71
+ this.auth = new AuthManager({ logger: this.logger });
72
+ for (const provider of opts.authProviders ?? [])
73
+ this.auth.register(provider);
74
+ // Subscription vendors declared in config become first-class auth providers.
75
+ for (const vendor of config.oauthProviders ?? []) {
76
+ this.auth.register(oauthProvider(vendor));
77
+ }
78
+ // The resolver is called on every request, so an expiring subscription
79
+ // token refreshes without the session noticing.
80
+ this.catalog = catalog;
81
+ this.plugins = plugins;
82
+ this.providers = ProviderRegistry.fromCatalog(catalog, {
83
+ creds: config.providers,
84
+ auth: (ctx) => this.auth.resolve(ctx),
85
+ });
86
+ // Every catalog provider gets an API-key login entry, so `auth login`
87
+ // covers whatever the catalog declares rather than a hardcoded three.
88
+ for (const entry of this.providers.listCatalogProviders()) {
89
+ if (this.auth.get(entry.id))
90
+ continue; // a richer provider already claimed it
91
+ this.auth.register(apiKeyProvider({
92
+ id: entry.id,
93
+ label: entry.label,
94
+ envVars: entry.envVars,
95
+ placeholder: entry.docsUrl ? `get one at ${entry.docsUrl}` : undefined,
96
+ }));
97
+ }
98
+ for (const vendor of config.openAiCompatible ?? []) {
99
+ this.providers.registerOpenAiCompatible(vendor);
100
+ }
101
+ for (const model of config.models ?? [])
102
+ this.providers.registerModel(model);
103
+ this.tools = new ToolRegistry().registerAll(builtinTools);
104
+ for (const tool of opts.tools ?? [])
105
+ this.tools.register(tool);
106
+ this.hooks = new HookRegistry(this.logger).loadShellHooks(config.hooks ?? {});
107
+ this.subagents = new SubagentRegistry([
108
+ ...BUILTIN_SUBAGENTS,
109
+ ...(config.subagents ?? []),
110
+ ]);
111
+ this.skills = new SkillRegistry(this.logger);
112
+ this.settings = new SettingsStore(this.cwd);
113
+ this.workspace = createPolicy(this.cwd, {
114
+ ...(config.workspaceMode !== undefined ? { mode: config.workspaceMode } : {}),
115
+ // Every directory a registry scans is one the agent can work in. A skill
116
+ // it is told about but cannot open is a skill it cannot follow, and one
117
+ // it cannot edit is a skill it cannot fix — `skillDirs` reaches outside
118
+ // the project by design, and stopping at the project boundary would put
119
+ // half of what the harness loads permanently out of reach.
120
+ //
121
+ // This includes the plugin directories read out of Claude Code's cache.
122
+ // Those belong to another program's installer, so the agent editing one
123
+ // edits something this harness did not put there; the alternative was a
124
+ // registry that advertises skills nobody can repair, and between the two
125
+ // this is the one that was asked for.
126
+ writableRoots: [
127
+ ...(config.writableRoots ?? []),
128
+ ...(config.skillDirs ?? []),
129
+ ...(config.agentDirs ?? []),
130
+ ],
131
+ ...(config.readableRoots !== undefined ? { readableRoots: config.readableRoots } : {}),
132
+ });
133
+ this.memory = new MemoryStore(config.memoryDir
134
+ ? path.resolve(this.cwd, config.memoryDir)
135
+ : path.join(this.cwd, ".oharness", "memory"));
136
+ this.mcp = new McpManager(this.logger, this.auth.store);
137
+ this.store = new SessionStore(config.sessionDir);
138
+ this.scheduler = new Scheduler(this, config.schedules ?? []);
139
+ this.ephemeral = opts.ephemeral === true;
140
+ }
141
+ static async create(opts = {}) {
142
+ const cwd = opts.cwd ?? process.cwd();
143
+ const baseConfig = opts.config ?? (await loadConfig(cwd));
144
+ const plugins = await discoverPlugins(cwd, baseConfig.plugins);
145
+ // Explicit user/project config is applied after plugins, so it can always
146
+ // tighten policy or replace a plugin's MCP server declaration.
147
+ const config = mergeConfig(pluginContributions(plugins), baseConfig);
148
+ // The catalog is data on disk, so it can be updated without a release.
149
+ const catalog = await loadCatalog(config.catalog ? [config.catalog] : []);
150
+ const harness = new Harness(opts, config, catalog, plugins);
151
+ harness.projectInstructions = await loadProjectInstructions(cwd, config.instructionsFile);
152
+ // Rules the user approved in earlier sessions live in settings files, not
153
+ // in the config the project checks in.
154
+ const persisted = await harness.settings.readAll();
155
+ harness.config.permissions = {
156
+ allow: [...(config.permissions?.allow ?? []), ...(persisted.allow ?? [])],
157
+ deny: [...(config.permissions?.deny ?? []), ...(persisted.deny ?? [])],
158
+ ask: [...(config.permissions?.ask ?? []), ...(persisted.ask ?? [])],
159
+ };
160
+ // Best effort: reclaiming disk should never block or fail a session.
161
+ if (config.sessionRetention && !opts.ephemeral) {
162
+ harness.store
163
+ .prune(config.sessionRetention)
164
+ .then((removed) => {
165
+ if (removed.length > 0) {
166
+ harness.logger.info("pruned old sessions", { count: removed.length });
167
+ }
168
+ })
169
+ .catch((err) => harness.logger.warn("session pruning failed", { error: String(err) }));
170
+ }
171
+ await harness.skills.discover(cwd, config.skillDirs ?? []);
172
+ // Markdown definitions layer over the JSON ones, so a repo can drop in an
173
+ // agent file without editing config.
174
+ for (const definition of await loadMarkdownSubagents(cwd, config.agentDirs ?? [])) {
175
+ harness.subagents.register(definition);
176
+ }
177
+ if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
178
+ const statuses = await harness.mcp.connectAll(config.mcpServers);
179
+ harness.tools.registerAll(harness.mcp.tools());
180
+ for (const status of statuses) {
181
+ if (status.error) {
182
+ harness.logger.warn("MCP server unavailable", {
183
+ server: status.name,
184
+ error: status.error,
185
+ });
186
+ }
187
+ }
188
+ }
189
+ // Awaited, though it touches no network: deciding whether to mirror reads
190
+ // config and asks git for the project id, and both are local. Leaving it
191
+ // unawaited made `syncStatus()` lie for the first few milliseconds of
192
+ // every process, which is exactly when a UI asks. The connecting happens
193
+ // inside the mirror and is not waited for.
194
+ await harness.startSync();
195
+ return harness;
196
+ }
197
+ /**
198
+ * Begin mirroring, if this machine is configured and signed in for it.
199
+ *
200
+ * Every reason not to is a normal state rather than an error — not signed
201
+ * in, switched off, this one repository excluded — so nothing here logs a
202
+ * failure for them. Only an actual attempt that goes wrong is worth a line.
203
+ */
204
+ async startSync() {
205
+ // An ephemeral harness has no session store to mirror; a subagent's would
206
+ // otherwise open a second connection for transcripts nobody keeps.
207
+ if (this.ephemeral)
208
+ return;
209
+ const decision = resolveSync({
210
+ config: this.config.sync,
211
+ accountToken: await this.accountToken(),
212
+ });
213
+ this.syncState = decision;
214
+ if (!decision.on)
215
+ return;
216
+ const project = await projectId(this.cwd);
217
+ if (isExcluded(this.config.sync, { id: project, cwd: this.cwd })) {
218
+ this.syncState = { on: false, reason: "excluded" };
219
+ return;
220
+ }
221
+ const configured = this.config.sync?.token ?? process.env["OHARNESS_SYNC_TOKEN"];
222
+ this.mirror = new SessionMirror({
223
+ store: this.store,
224
+ client: new SyncClient({
225
+ url: decision.target.url,
226
+ // Read per request rather than captured: an account token lasts an
227
+ // hour and is refreshed underneath, and a mirror holding the string it
228
+ // started with would stop for good once that hour was up.
229
+ token: configured ? configured : async () => (await this.accountToken()) ?? "",
230
+ ...(this.config.sync?.team ? { team: this.config.sync.team } : {}),
231
+ }),
232
+ projectId: project,
233
+ onError: (message) => this.logger.warn(message),
234
+ });
235
+ this.mirror.start();
236
+ }
237
+ /**
238
+ * The roots this machine was told to allow, as opposed to the ones it
239
+ * worked out.
240
+ *
241
+ * `workspace.writableRoots` is the sum of a choice and a derivation: what
242
+ * the config named, plus every skill and agent directory the installed
243
+ * plugins contributed. Only the first half is editable, and telling them
244
+ * apart is the difference between a settings row that works and one whose
245
+ * changes come back on the next launch.
246
+ */
247
+ configuredWritableRoots() {
248
+ return [...(this.config.writableRoots ?? [])];
249
+ }
250
+ /**
251
+ * Replace the roots this machine allows writes to.
252
+ *
253
+ * Takes effect on the next launch rather than now: the workspace policy is
254
+ * built once and handed to every tool, and swapping it under a turn already
255
+ * in flight would let a command that was checked against one answer run
256
+ * against another.
257
+ */
258
+ async setWritableRoots(roots) {
259
+ const cleaned = [...new Set(roots.map((root) => root.trim()).filter(Boolean))];
260
+ await updateUserConfig((current) => ({ ...current, writableRoots: cleaned }));
261
+ return cleaned;
262
+ }
263
+ /**
264
+ * Whether permission decisions are being recorded, and who decided that.
265
+ *
266
+ * Recording is on unless somebody turned it off: the trail is a handful of
267
+ * short lines per turn, it never leaves the machine, and the state where it
268
+ * is missing is exactly the state where it turns out to have been wanted.
269
+ *
270
+ * The source travels with the answer for the same reason sync carries a
271
+ * reason: a merged config cannot tell a value the user chose from one the
272
+ * repository imposed, and only the first can be changed from here. A switch
273
+ * offered over the second would appear to work and come back on the next
274
+ * launch, which is worse than no switch at all.
275
+ */
276
+ async auditStatus() {
277
+ // Best effort: a project config that has since become unreadable should
278
+ // not take the settings page down with it. Treated as "not the project's",
279
+ // which is the state the user can still act on.
280
+ const project = await loadProjectConfig(this.cwd).catch(() => undefined);
281
+ const file = this.config.auditLog;
282
+ return {
283
+ on: this.config.audit !== false,
284
+ ...(file ? { file, path: path.resolve(this.cwd, file) } : {}),
285
+ source: project?.audit === undefined ? "user" : "project",
286
+ };
287
+ }
288
+ /**
289
+ * Start or stop recording permission decisions, and remember the choice.
290
+ *
291
+ * Only the recording is a choice here. Where an extra copy is written is
292
+ * `auditLog`, and that stays where somebody typed it: a switch that also
293
+ * deleted a configured path would turn "stop recording for now" into
294
+ * "forget where my collector reads from".
295
+ *
296
+ * On is written as the *absence* of the key, because on is the default and a
297
+ * config file should say what somebody decided, not restate what they left
298
+ * alone. Takes effect on the sessions already open — see `equip`.
299
+ */
300
+ async setAudit(enabled) {
301
+ const status = await this.auditStatus();
302
+ if (status.source === "project") {
303
+ throw new ConfigError("this project's config decides the audit trail, so it cannot be changed here");
304
+ }
305
+ if (status.on === enabled)
306
+ return status;
307
+ this.config.audit = enabled;
308
+ await updateUserConfig((current) => {
309
+ const { audit: _previous, ...rest } = current;
310
+ return enabled ? rest : { ...rest, audit: false };
311
+ });
312
+ return this.auditStatus();
313
+ }
314
+ /** A session's permission trail, oldest first. Empty for an ephemeral one. */
315
+ async decisions(sessionId) {
316
+ if (this.ephemeral)
317
+ return [];
318
+ return this.store.decisions(sessionId);
319
+ }
320
+ /**
321
+ * Push whatever is waiting, now.
322
+ *
323
+ * The mirror pushes on its own — this is for the person who wants to know it
324
+ * has happened before closing the laptop, which is not a question a
325
+ * background timer can answer. False means there was nothing to push it
326
+ * through: off, excluded, or not signed in.
327
+ */
328
+ async syncNow() {
329
+ if (!this.mirror)
330
+ return false;
331
+ await this.mirror.flush();
332
+ return true;
333
+ }
334
+ /**
335
+ * The OHarness account access token, if signed in.
336
+ *
337
+ * Only `official` is asked. Other gateway entries in the catalog hold keys
338
+ * to *their* vendor, not to an OHarness account, and handing one of those to
339
+ * the artifact API would authenticate as nobody — the 401 that follows is a
340
+ * far worse way to find out than not trying.
341
+ *
342
+ * Public because publishing an artifact needs it too, and a second copy of
343
+ * this rule in the server package would be a second place to get it wrong.
344
+ */
345
+ async accountToken() {
346
+ return await this.auth.accessToken("official").catch(() => undefined);
347
+ }
348
+ /**
349
+ * Whether transcripts are leaving this machine, and why not when they are
350
+ * not.
351
+ *
352
+ * The reason is carried rather than collapsed to a boolean because "you
353
+ * turned this off" and "you are not signed in" look identical from outside
354
+ * and need opposite advice.
355
+ */
356
+ syncStatus() {
357
+ if (this.syncState.on) {
358
+ return { on: true, url: this.syncState.target.url };
359
+ }
360
+ return { on: false, reason: this.syncState.reason };
361
+ }
362
+ /**
363
+ * Turn sync on or off now, and remember the choice.
364
+ *
365
+ * Written to the user's config rather than held in memory, because a switch
366
+ * that forgets on restart is not a switch — someone who turned sync off did
367
+ * not mean "until the next launch". Stopping aborts the poll in flight, so
368
+ * the connection does not outlive the decision.
369
+ */
370
+ async setSyncEnabled(enabled) {
371
+ this.config.sync = { ...this.config.sync, enabled };
372
+ await updateUserConfig((current) => ({
373
+ ...current,
374
+ sync: { ...current.sync, enabled },
375
+ }));
376
+ if (enabled) {
377
+ if (!this.mirror)
378
+ await this.startSync();
379
+ else
380
+ this.mirror.start();
381
+ return;
382
+ }
383
+ this.mirror?.stop();
384
+ this.mirror = undefined;
385
+ this.syncState = { on: false, reason: "disabled" };
386
+ }
387
+ /** Connect an MCP server at runtime and expose its tools immediately. */
388
+ async addMcpServer(name, config) {
389
+ this.tools.unregisterServer(name);
390
+ const tools = await this.mcp.connect(name, config);
391
+ this.tools.registerAll(tools);
392
+ return (this.mcp.status().find((s) => s.name === name) ?? {
393
+ name,
394
+ connected: false,
395
+ transport: config.type,
396
+ toolCount: 0,
397
+ });
398
+ }
399
+ /**
400
+ * Begin an OAuth login for a connector that asked for one.
401
+ *
402
+ * The connector does not have to be connected — a server that answered 401
403
+ * is exactly the case this exists for. On success the server is reconnected,
404
+ * so the tools appear without the user doing anything else.
405
+ */
406
+ async mcpLogin(name) {
407
+ const config = this.mcp.configFor(name);
408
+ if (!config)
409
+ throw new ConfigError(`no MCP server named "${name}"`);
410
+ if (config.type !== "http") {
411
+ throw new ConfigError(`"${name}" runs locally and has nothing to log in to`);
412
+ }
413
+ const expanded = expandSecrets(config);
414
+ const challenge = this.mcp.authChallenge(name);
415
+ // The challenge's URL is the one the server actually answered on, which is
416
+ // what its authorization server will have been told about.
417
+ const url = challenge?.url ?? (expanded.type === "http" ? expanded.url : "");
418
+ const result = await beginMcpLogin({
419
+ serverUrl: url,
420
+ store: this.auth.store,
421
+ ...(challenge?.challenge ? { challenge: challenge.challenge } : {}),
422
+ });
423
+ if (result.method !== "auto")
424
+ return result;
425
+ const finish = result.callback;
426
+ return {
427
+ ...result,
428
+ method: "auto",
429
+ callback: async () => {
430
+ const outcome = await finish();
431
+ if (outcome.type === "success")
432
+ await this.addMcpServer(name, config);
433
+ return outcome;
434
+ },
435
+ };
436
+ }
437
+ /** Forget a connector's credential. It will ask again on the next connect. */
438
+ async mcpLogout(name) {
439
+ const config = this.mcp.configFor(name);
440
+ if (!config || config.type !== "http")
441
+ return false;
442
+ const expanded = expandSecrets(config);
443
+ if (expanded.type !== "http")
444
+ return false;
445
+ return forgetMcpAuth(expanded.url, this.auth.store);
446
+ }
447
+ async removeMcpServer(name) {
448
+ // `remove`, not `disconnect`: an explicitly removed server must not come
449
+ // back through the reconnect path on the next tool call.
450
+ await this.mcp.remove(name);
451
+ this.tools.unregisterServer(name);
452
+ }
453
+ mcpStatus() {
454
+ return this.mcp.status();
455
+ }
456
+ authStatus() {
457
+ return this.auth.status();
458
+ }
459
+ /** Providers that can actually authenticate right now. */
460
+ async configuredProviders() {
461
+ const out = new Set();
462
+ for (const provider of this.providers.listProviders()) {
463
+ if (provider.isConfigured() || (await this.auth.isAuthenticated(provider.id))) {
464
+ out.add(provider.id);
465
+ }
466
+ }
467
+ return out;
468
+ }
469
+ /**
470
+ * Ask providers which models they actually serve, and persist the result to
471
+ * the user catalog so the next run starts with a current list.
472
+ */
473
+ async refreshModels(providerId) {
474
+ const discovered = await this.providers.refreshModels(providerId);
475
+ if (discovered.length > 0) {
476
+ await this.saveDiscovered(discovered).catch((err) => this.logger.warn("could not persist the discovered catalog", {
477
+ error: String(err),
478
+ }));
479
+ }
480
+ return discovered;
481
+ }
482
+ /** Merge discovered models into the user catalog on disk. */
483
+ async saveDiscovered(models) {
484
+ const file = userCatalogPath();
485
+ let existing = {};
486
+ try {
487
+ existing = JSON.parse(await readFile(file, "utf8"));
488
+ }
489
+ catch {
490
+ // No user catalog yet; start one.
491
+ }
492
+ const byId = new Map((Array.isArray(existing["models"]) ? existing["models"] : [])
493
+ .filter((m) => !!m && typeof m === "object")
494
+ .map((m) => [String(m["id"]), m]));
495
+ for (const model of models)
496
+ byId.set(model.id, model);
497
+ await mkdir(path.dirname(file), { recursive: true });
498
+ await writeFile(file, JSON.stringify({ ...existing, discoveredAt: new Date().toISOString(), models: [...byId.values()] }, null, 2), "utf8");
499
+ }
500
+ /**
501
+ * Re-scan the skill directories.
502
+ *
503
+ * Skills are ordinary markdown on disk and the agent can write them, so
504
+ * without this an authored skill sits unusable until the next restart —
505
+ * which breaks the one loop that would let the agent improve itself.
506
+ */
507
+ async reloadSkills() {
508
+ await this.skills.discover(this.cwd, this.config.skillDirs ?? []);
509
+ return this.skills.size;
510
+ }
511
+ /** Providers declared by the catalog, for `/models` and login hints. */
512
+ listCatalogProviders() {
513
+ return this.providers.listCatalogProviders();
514
+ }
515
+ listModels(configuredOnly = false) {
516
+ return this.providers.listModels({ configuredOnly });
517
+ }
518
+ listSessions(limit) {
519
+ return this.store.list(limit);
520
+ }
521
+ /** Start a fresh session. */
522
+ createSession(overrides = {}) {
523
+ return this.equip(new Session({
524
+ cwd: this.cwd,
525
+ model: overrides.model ?? this.config.model ?? this.defaultModel(),
526
+ providers: this.providers,
527
+ permissionMode: this.config.permissionMode,
528
+ permissionRules: this.config.permissions,
529
+ store: this.options.ephemeral ? false : this.store,
530
+ title: overrides.title,
531
+ }));
532
+ }
533
+ /**
534
+ * Give a session the machinery that outlives it: where remembered approvals
535
+ * are written, and where decisions are recorded.
536
+ */
537
+ auditQueue = Promise.resolve();
538
+ /**
539
+ * The file the queue has already made a directory for.
540
+ *
541
+ * A path rather than a flag, because recording can be switched on and off
542
+ * while sessions are open: a boolean would remember that *some* directory
543
+ * exists and let the first write to a new one ENOENT into a log line.
544
+ */
545
+ auditReadyFor;
546
+ equip(session) {
547
+ session.permissions.attachSettings(this.settings);
548
+ // Attached whatever the setting says, and the setting read when a decision
549
+ // actually happens. Deciding here instead would make the switch on the
550
+ // settings page a lie for every session already open — one started with
551
+ // recording off could never begin, and one started with it on could never
552
+ // stop.
553
+ session.permissions.onDecision((entry) => {
554
+ if (this.config.audit === false)
555
+ return;
556
+ this.record(session.id, entry);
557
+ });
558
+ return session;
559
+ }
560
+ /**
561
+ * Write one trail entry: to the session's own log, and to the configured
562
+ * file when there is one.
563
+ *
564
+ * Fire-and-forget so recording never delays or fails a tool call, but
565
+ * chained: concurrent appends of separate lines can interleave and a
566
+ * half-written record is worse than none. The chain also carries the
567
+ * one-time mkdir for the file sink — without it every write ENOENTs into a
568
+ * log line and the trail is silently empty.
569
+ *
570
+ * This is the one place this harness parts company with a stricter reading
571
+ * of the same idea, where a decision that cannot be recorded is a decision
572
+ * that does not happen. That trade buys an audit you can rely on at the cost
573
+ * of a tool call that fails because a disk did; here the tool call wins, and
574
+ * a failed write is a warning in the log rather than a refusal. Anything
575
+ * that needs the stronger guarantee needs this to be awaited on the call
576
+ * path, not just louder.
577
+ */
578
+ record(sessionId, entry) {
579
+ const configured = this.config.auditLog;
580
+ this.auditQueue = this.auditQueue
581
+ .then(async () => {
582
+ // An ephemeral harness has no store to write to, and inventing one
583
+ // for the trail would give a session that deliberately leaves nothing
584
+ // behind a file that outlives it.
585
+ if (!this.ephemeral)
586
+ await this.store.appendDecision(sessionId, entry);
587
+ if (!configured)
588
+ return;
589
+ const file = path.resolve(this.cwd, configured);
590
+ if (this.auditReadyFor !== file) {
591
+ await mkdir(path.dirname(file), { recursive: true });
592
+ this.auditReadyFor = file;
593
+ }
594
+ await appendFile(file, `${JSON.stringify({ ...entry, session: sessionId })}\n`, "utf8");
595
+ })
596
+ .catch((err) => this.logger.warn("audit write failed", { error: String(err) }));
597
+ }
598
+ /**
599
+ * Branch a session. Omit `keepMessages` to branch from where it stands, or
600
+ * pass a count to rewind the branch to an earlier point.
601
+ */
602
+ async forkSession(id, opts = {}) {
603
+ const forked = await Session.fork(id, {
604
+ model: opts.model ?? this.config.model ?? this.defaultModel(),
605
+ providers: this.providers,
606
+ permissionMode: this.config.permissionMode,
607
+ permissionRules: this.config.permissions,
608
+ store: this.store,
609
+ ...(opts.keepMessages !== undefined ? { keepMessages: opts.keepMessages } : {}),
610
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
611
+ });
612
+ return forked ? this.equip(forked) : undefined;
613
+ }
614
+ deleteSession(id) {
615
+ return this.store.delete(id);
616
+ }
617
+ /**
618
+ * Delete every session recorded in one tree.
619
+ *
620
+ * The directory itself is never touched — this throws away what the harness
621
+ * wrote about the work, not the work. Returns the ids, because whoever asked
622
+ * is probably holding some of them open.
623
+ */
624
+ deleteSessionsIn(cwd) {
625
+ return this.store.deleteIn(cwd);
626
+ }
627
+ renameSession(id, title) {
628
+ return this.store.rename(id, title);
629
+ }
630
+ /** Reload a persisted session by id; returns undefined if it is unknown. */
631
+ async resumeSession(id, model) {
632
+ const resumed = await Session.resume(id, {
633
+ model: model ?? this.config.model ?? this.defaultModel(),
634
+ providers: this.providers,
635
+ permissionMode: this.config.permissionMode,
636
+ permissionRules: this.config.permissions,
637
+ store: this.store,
638
+ });
639
+ return resumed ? this.equip(resumed) : undefined;
640
+ }
641
+ /** Build an agent bound to a session, wired with everything the harness owns. */
642
+ agent(session, overrides = {}) {
643
+ return new Agent({
644
+ session,
645
+ tools: this.tools,
646
+ hooks: this.hooks,
647
+ subagents: this.subagents,
648
+ skills: this.skills,
649
+ memory: this.memory,
650
+ onSkillsChanged: () => this.reloadSkills(),
651
+ mcp: this.mcp,
652
+ permissionPrompt: this.options.permissionPrompt,
653
+ workspace: this.workspace,
654
+ systemPrompt: this.config.systemPrompt,
655
+ smallModel: this.config.smallModel,
656
+ appendSystemPrompt: this.config.appendSystemPrompt,
657
+ projectInstructions: this.projectInstructions,
658
+ toolFilter: this.config.tools,
659
+ thinking: this.config.thinking,
660
+ maxTurns: this.config.maxTurns,
661
+ cache: this.config.cache,
662
+ logger: this.logger,
663
+ ...overrides,
664
+ });
665
+ }
666
+ /**
667
+ * One-shot convenience: run a prompt in a new session and stream events.
668
+ * For multi-turn use, keep a `Session` and reuse the agent.
669
+ */
670
+ async *run(prompt, opts = {}) {
671
+ const session = this.createSession({ model: opts.model });
672
+ const agent = this.agent(session);
673
+ yield* agent.run(prompt, { signal: opts.signal });
674
+ }
675
+ async close() {
676
+ // Stopped before MCP, because it is the only thing here holding a socket
677
+ // open on purpose — a long poll would otherwise keep the process alive for
678
+ // up to thirty seconds after everything else had shut down.
679
+ this.mirror?.stop();
680
+ await this.mcp.closeAll();
681
+ }
682
+ /**
683
+ * First model whose provider has credentials. Only the cheap synchronous
684
+ * check runs here — a subscription-only provider is picked up once the
685
+ * caller has awaited `configuredProviders()`.
686
+ */
687
+ defaultModel() {
688
+ const configured = this.providers.listModels({ configuredOnly: true });
689
+ const first = configured[0] ?? this.providers.listModels()[0];
690
+ if (!first)
691
+ throw new Error("no models are registered");
692
+ return first.id;
693
+ }
694
+ }
695
+ //# sourceMappingURL=harness.js.map