thoth-agents 0.11.0 → 0.13.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 (95) hide show
  1. package/README.md +16 -5
  2. package/dist/agents/agent-config.d.ts +13 -0
  3. package/dist/agents/index.d.ts +1 -1
  4. package/dist/agents/orchestrator.d.ts +1 -1
  5. package/dist/{chunk-I5TXGNSG.js → chunk-5MYXVRZZ.js} +634 -366
  6. package/dist/chunk-6O4GKDS7.js +207 -0
  7. package/dist/{chunk-RWQPMWEM.js → chunk-JLU3FSBT.js} +17 -2
  8. package/dist/chunk-MIDGZE34.js +107 -0
  9. package/dist/{chunk-HACUJFKI.js → chunk-NOLDFT7O.js} +4 -230
  10. package/dist/{chunk-5WPP6IY7.js → chunk-VVYAQTYE.js} +2 -2
  11. package/dist/{chunk-DNPKVFOB.js → chunk-YCWGTDFE.js} +604 -315
  12. package/dist/cli/config-io.d.ts +2 -2
  13. package/dist/cli/index.js +94 -41
  14. package/dist/cli/install.d.ts +1 -0
  15. package/dist/cli/opencode-profiles.d.ts +10 -0
  16. package/dist/cli/opencode-skill-consent.d.ts +5 -0
  17. package/dist/cli/operations/opencode.d.ts +2 -1
  18. package/dist/cli/operations/types.d.ts +1 -0
  19. package/dist/cli/owned-skills.d.ts +16 -0
  20. package/dist/cli/paths.d.ts +1 -1
  21. package/dist/cli/providers.d.ts +1 -1
  22. package/dist/cli/system.d.ts +3 -1
  23. package/dist/cli/thoth-mem-install.d.ts +10 -10
  24. package/dist/cli/tui/index.js +53 -5
  25. package/dist/cli/tui/operations.d.ts +2 -0
  26. package/dist/cli/types.d.ts +2 -4
  27. package/dist/config/loader.d.ts +2 -5
  28. package/dist/config/schema.d.ts +0 -53
  29. package/dist/harness/adapters/opencode.d.ts +1 -1
  30. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -8
  31. package/dist/hooks/foreground-fallback/index.d.ts +14 -69
  32. package/dist/hooks/index.d.ts +0 -2
  33. package/dist/hooks/json-error-recovery/hook.d.ts +2 -15
  34. package/dist/hooks/oracle-gate-verdict/hook.d.ts +4 -11
  35. package/dist/index.d.ts +7 -4
  36. package/dist/index.js +1603 -1408
  37. package/dist/opencode/agents.d.ts +3 -0
  38. package/dist/opencode/command.d.ts +3 -0
  39. package/dist/opencode/context.d.ts +36 -0
  40. package/dist/opencode/delegation-hooks.d.ts +3 -0
  41. package/dist/opencode/mcp.d.ts +3 -0
  42. package/dist/opencode/permission-rules.d.ts +9 -0
  43. package/dist/opencode/permissions.d.ts +3 -0
  44. package/dist/opencode/profile-rpc-handlers.d.ts +3 -0
  45. package/dist/opencode/profile-rpc.d.ts +688 -0
  46. package/dist/opencode/profile-runtime.d.ts +3 -0
  47. package/dist/opencode/profile-sessions.d.ts +18 -0
  48. package/dist/opencode/profiles/editor.d.ts +44 -0
  49. package/dist/opencode/profiles/index.d.ts +18 -0
  50. package/dist/opencode/profiles/resolver.d.ts +12 -0
  51. package/dist/opencode/profiles/store.d.ts +63 -0
  52. package/dist/opencode/profiles/types.d.ts +26 -0
  53. package/dist/opencode/session-hooks.d.ts +3 -0
  54. package/dist/opencode/session-state.d.ts +39 -0
  55. package/dist/opencode/skills.d.ts +3 -0
  56. package/dist/opencode/tool-overrides/evaluator.d.ts +13 -0
  57. package/dist/opencode/tool-overrides/index.d.ts +5 -0
  58. package/dist/opencode/tool-overrides/store.d.ts +38 -0
  59. package/dist/opencode/tool-overrides/types.d.ts +32 -0
  60. package/dist/opencode/tool-overrides/validation.d.ts +5 -0
  61. package/dist/opencode/tool-overrides/wildcard.d.ts +3 -0
  62. package/dist/opencode/tool-rpc-handlers.d.ts +3 -0
  63. package/dist/opencode/tool-rpc.d.ts +428 -0
  64. package/dist/opencode/tools.d.ts +3 -0
  65. package/dist/pi.js +3 -3
  66. package/dist/tools/ast-grep/cli.d.ts +1 -0
  67. package/dist/tools/ast-grep/index.d.ts +108 -2
  68. package/dist/tools/ast-grep/tools.d.ts +107 -3
  69. package/dist/tools/define-tool.d.ts +23 -0
  70. package/dist/tools/lsp/tools.d.ts +97 -5
  71. package/dist/tui/fixtures.d.ts +2 -0
  72. package/dist/tui/flow.d.ts +16 -0
  73. package/dist/tui/index.d.ts +7 -0
  74. package/dist/tui/index.js +1384 -0
  75. package/dist/tui/model.d.ts +84 -0
  76. package/dist/tui/tools/fixtures.d.ts +3 -0
  77. package/dist/tui/tools/flow.d.ts +11 -0
  78. package/dist/tui/tools/model.d.ts +97 -0
  79. package/dist/utils/index.d.ts +0 -1
  80. package/package.json +18 -3
  81. package/plugin/.claude-plugin/plugin.json +1 -1
  82. package/plugin/.codex-plugin/plugin.json +1 -1
  83. package/plugin/skills/thoth-sdd/SKILL.md +8 -2
  84. package/plugin/skills/thoth-sdd/scripts/validate-result.mjs +28 -2
  85. package/skills/thoth-sdd/SKILL.md +8 -2
  86. package/skills/thoth-sdd/scripts/validate-result.mjs +28 -2
  87. package/thoth-agents.schema.json +0 -96
  88. package/dist/chunk-62L3ORXN.js +0 -30
  89. package/dist/delegation/project-id.d.ts +0 -10
  90. package/dist/hooks/auto-update-checker/checker.d.ts +0 -23
  91. package/dist/hooks/auto-update-checker/constants.d.ts +0 -11
  92. package/dist/hooks/auto-update-checker/index.d.ts +0 -17
  93. package/dist/hooks/auto-update-checker/types.d.ts +0 -22
  94. package/dist/utils/tmux-session-manager.d.ts +0 -63
  95. package/dist/utils/tmux.d.ts +0 -32
package/dist/index.js CHANGED
@@ -1,28 +1,320 @@
1
1
  import {
2
2
  formatGateVerdict,
3
3
  summarizeGateResult
4
- } from "./chunk-RWQPMWEM.js";
4
+ } from "./chunk-JLU3FSBT.js";
5
+ import {
6
+ profileRpc,
7
+ toolRpc
8
+ } from "./chunk-6O4GKDS7.js";
5
9
  import {
6
10
  createAgents,
7
- renderOpenCodeAgentConfigs,
8
- spawn,
9
- spawnSync
10
- } from "./chunk-HACUJFKI.js";
11
+ renderOpenCodeAgentConfigs
12
+ } from "./chunk-NOLDFT7O.js";
11
13
  import {
12
14
  context7,
13
15
  exa,
14
- grep_app
15
- } from "./chunk-62L3ORXN.js";
16
+ grep_app,
17
+ spawn,
18
+ spawnSync
19
+ } from "./chunk-MIDGZE34.js";
16
20
  import {
17
- POLL_INTERVAL_BACKGROUND_MS,
18
- getAgentRole,
19
- getOpenCodeConfigPaths,
21
+ ModelProfileStore,
22
+ PROFILE_ROLES,
23
+ ProfileConflictError,
24
+ formatModelString,
20
25
  loadPluginConfig,
21
- stripJsonComments
22
- } from "./chunk-DNPKVFOB.js";
26
+ mergeProfileModelChain,
27
+ resolveRoleProfile,
28
+ validateProfileName
29
+ } from "./chunk-YCWGTDFE.js";
23
30
 
24
- // src/index.ts
25
- import { fileURLToPath as fileURLToPath3 } from "url";
31
+ // src/opencode/permission-rules.ts
32
+ var ACTIONS = {
33
+ read: "read",
34
+ glob: "glob",
35
+ grep: "grep",
36
+ list: "read",
37
+ codesearch: "read",
38
+ lsp: "read",
39
+ edit: "edit",
40
+ write: "edit",
41
+ patch: "edit",
42
+ bash: "shell",
43
+ task: "subagent",
44
+ webfetch: "webfetch",
45
+ exa: "exa_*",
46
+ external_directory: "external_directory",
47
+ skill: "skill",
48
+ question: "question",
49
+ todowrite: "todowrite"
50
+ };
51
+ var RESTRICTION = { allow: 0, ask: 1, deny: 2 };
52
+ function specificity(rule) {
53
+ return Number(rule.action !== "*") + 2 * Number(rule.resource !== "*");
54
+ }
55
+ function translatePermissionRules(permission) {
56
+ if (typeof permission === "string") {
57
+ return [{ action: "*", resource: "*", effect: permission }];
58
+ }
59
+ const rules = [];
60
+ for (const [key, value] of Object.entries(permission ?? {})) {
61
+ if (!value) continue;
62
+ const action = ACTIONS[key] ?? key;
63
+ const entries = typeof value === "string" ? [["*", value]] : Object.entries(value);
64
+ for (const [resource, effect] of entries) {
65
+ const existing = rules.find(
66
+ (rule) => rule.action === action && rule.resource === resource
67
+ );
68
+ if (!existing) rules.push({ action, resource, effect });
69
+ else if (RESTRICTION[effect] > RESTRICTION[existing.effect])
70
+ existing.effect = effect;
71
+ }
72
+ }
73
+ return [
74
+ { action: "*", resource: "*", effect: "ask" },
75
+ ...rules.sort((a, b) => specificity(a) - specificity(b))
76
+ ];
77
+ }
78
+
79
+ // src/opencode/tool-overrides/wildcard.ts
80
+ function matchAction(input, pattern, platform = process.platform) {
81
+ const normalized = input.replaceAll("\\", "/");
82
+ let escaped = pattern.replaceAll("\\", "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
83
+ if (escaped.endsWith(" .*")) escaped = `${escaped.slice(0, -3)}( .*)?`;
84
+ return new RegExp(`^${escaped}$`, platform === "win32" ? "si" : "s").test(
85
+ normalized
86
+ );
87
+ }
88
+ function countActionMatches(pattern, actions, platform = process.platform) {
89
+ return actions.filter((action) => matchAction(action, pattern, platform)).length;
90
+ }
91
+
92
+ // src/opencode/tool-overrides/evaluator.ts
93
+ function appendOverrideRules(overrides) {
94
+ if (!overrides.length) return [];
95
+ return [
96
+ ...overrides.map((override) => ({ ...override, resource: "*" })),
97
+ { action: "subagent", resource: "*", effect: "deny" }
98
+ ];
99
+ }
100
+ function evaluateAction(base, effective, action, platform = process.platform) {
101
+ let result = {
102
+ effect: "ask",
103
+ origin: "default"
104
+ };
105
+ for (const rule of base) {
106
+ if (matchAction(action, rule.action, platform) && matchAction("*", rule.resource, platform))
107
+ result = { effect: rule.effect, origin: "default" };
108
+ }
109
+ if (effective) {
110
+ const rules = appendOverrideRules(effective.overrides);
111
+ for (const [index, rule] of rules.entries()) {
112
+ if (matchAction(action, rule.action, platform))
113
+ result = {
114
+ effect: rule.effect,
115
+ origin: index === rules.length - 1 ? "default" : effective.origin
116
+ };
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ // src/opencode/tool-overrides/store.ts
123
+ import { createHash, randomUUID } from "crypto";
124
+ import fs from "fs";
125
+ import os from "os";
126
+ import path from "path";
127
+
128
+ // src/opencode/tool-overrides/types.ts
129
+ var SPECIALIST_ROLES = PROFILE_ROLES.filter(
130
+ (role) => role !== "orchestrator"
131
+ );
132
+
133
+ // src/opencode/tool-overrides/validation.ts
134
+ function object(value) {
135
+ return value !== null && typeof value === "object" && !Array.isArray(value);
136
+ }
137
+ function parseActionOverrides(value) {
138
+ if (!Array.isArray(value)) throw new Error("Expected an overrides array");
139
+ return value.map((entry) => {
140
+ if (!object(entry) || typeof entry.action !== "string" || !entry.action.trim())
141
+ throw new Error("Action must be a non-empty pattern");
142
+ if (entry.effect !== "allow" && entry.effect !== "ask" && entry.effect !== "deny")
143
+ throw new Error("Effect must be allow, ask or deny");
144
+ return { action: entry.action.trim(), effect: entry.effect };
145
+ });
146
+ }
147
+ function validateSpecialistRole(role) {
148
+ const canonical = SPECIALIST_ROLES.find((name) => name === role);
149
+ if (!canonical) throw new Error(`Unknown specialist role: ${role}`);
150
+ return canonical;
151
+ }
152
+ function parseToolsDocument(value, warnings = []) {
153
+ if (!object(value) || value.version !== 1 || !object(value.roles))
154
+ throw new Error("Expected version 1 and a roles object");
155
+ const roles = {};
156
+ for (const [role, entry] of Object.entries(value.roles)) {
157
+ const canonical = SPECIALIST_ROLES.find((name) => name === role);
158
+ if (!canonical) {
159
+ warnings.push(`Unknown specialist role ignored: ${role}`);
160
+ continue;
161
+ }
162
+ if (!object(entry)) throw new Error(`Invalid overrides for ${role}`);
163
+ roles[canonical] = { overrides: parseActionOverrides(entry.overrides) };
164
+ }
165
+ return { version: 1, roles };
166
+ }
167
+
168
+ // src/opencode/tool-overrides/store.ts
169
+ function toolsOverridePaths(options) {
170
+ if (options.paths) return { ...options.paths };
171
+ const env = options.env ?? process.env;
172
+ const configDir = options.configDir ?? (env.OPENCODE_CONFIG_DIR?.trim() || path.join(
173
+ env.XDG_CONFIG_HOME || path.join(options.homeDir ?? os.homedir(), ".config"),
174
+ "opencode"
175
+ ));
176
+ return {
177
+ global: path.join(configDir, "thoth", "tools.json"),
178
+ project: path.join(options.cwd, ".opencode", "thoth", "tools.json")
179
+ };
180
+ }
181
+ var emptyDocument = () => ({ version: 1, roles: {} });
182
+ var revisionOf = (raw) => raw === null ? "missing" : createHash("sha256").update(raw).digest("hex");
183
+ var ToolsConflictError = class extends Error {
184
+ constructor(scope) {
185
+ super(`Tools changed on disk: ${scope}; reload before saving.`);
186
+ this.scope = scope;
187
+ this.name = "ToolsConflictError";
188
+ }
189
+ scope;
190
+ code = "TOOLS_CONFLICT";
191
+ };
192
+ var ToolsOverrideStore = class {
193
+ constructor(options) {
194
+ this.options = options;
195
+ this.paths = toolsOverridePaths(options);
196
+ }
197
+ options;
198
+ paths;
199
+ raw(scope) {
200
+ try {
201
+ return fs.readFileSync(this.paths[scope], "utf8");
202
+ } catch (error) {
203
+ if (error.code === "ENOENT") return null;
204
+ throw error;
205
+ }
206
+ }
207
+ read(scope) {
208
+ const warnings = [];
209
+ let raw = null;
210
+ let document = emptyDocument();
211
+ try {
212
+ raw = this.raw(scope);
213
+ if (raw !== null)
214
+ document = parseToolsDocument(JSON.parse(raw), warnings);
215
+ } catch (error) {
216
+ warnings.push(`Cannot read tools ${this.paths[scope]}: ${String(error)}`);
217
+ }
218
+ for (const warning of warnings) this.options.warn?.(warning);
219
+ return { document, revision: revisionOf(raw), warnings };
220
+ }
221
+ resolve() {
222
+ const global = this.read("global");
223
+ const project = this.read("project");
224
+ const effective = {};
225
+ for (const role of SPECIALIST_ROLES) {
226
+ const origin = Object.hasOwn(project.document.roles, role) ? "project" : "global";
227
+ const entry = (origin === "project" ? project : global).document.roles[role];
228
+ if (entry) effective[role] = { ...entry, origin };
229
+ }
230
+ return {
231
+ global,
232
+ project,
233
+ effective,
234
+ warnings: [...global.warnings, ...project.warnings]
235
+ };
236
+ }
237
+ mutate(scope, role, baseRevision, update) {
238
+ validateSpecialistRole(role);
239
+ const raw = this.raw(scope);
240
+ if (revisionOf(raw) !== baseRevision) throw new ToolsConflictError(scope);
241
+ const document = raw === null ? emptyDocument() : parseToolsDocument(JSON.parse(raw));
242
+ update(document);
243
+ const file = this.paths[scope];
244
+ fs.mkdirSync(path.dirname(file), { recursive: true });
245
+ const temp = `${file}.${randomUUID()}.tmp`;
246
+ try {
247
+ fs.writeFileSync(temp, `${JSON.stringify(document, null, 2)}
248
+ `, {
249
+ encoding: "utf8",
250
+ flag: "wx"
251
+ });
252
+ if (revisionOf(this.raw(scope)) !== baseRevision)
253
+ throw new ToolsConflictError(scope);
254
+ fs.renameSync(temp, file);
255
+ } finally {
256
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
257
+ }
258
+ return this.read(scope);
259
+ }
260
+ /** Save ordered rules losslessly, including duplicates; other roles survive. */
261
+ save(scope, role, overrides, baseRevision) {
262
+ const next = parseActionOverrides(overrides);
263
+ return this.mutate(scope, role, baseRevision, (document) => {
264
+ document.roles[role] = { overrides: next };
265
+ });
266
+ }
267
+ /** Remove a scope's role entry (project reset resumes global inheritance). */
268
+ reset(scope, role, baseRevision) {
269
+ return this.mutate(scope, role, baseRevision, (document) => {
270
+ delete document.roles[role];
271
+ });
272
+ }
273
+ };
274
+
275
+ // src/opencode/agents.ts
276
+ var ROLE_COLORS = {
277
+ orchestrator: "#7c3aed",
278
+ explorer: "#0ea5e9",
279
+ librarian: "#0284c7",
280
+ oracle: "#f59e0b",
281
+ designer: "#ec4899",
282
+ worker: "#10b981"
283
+ };
284
+ async function registerAgents(ctx, deps) {
285
+ await ctx.agent.transform((editor) => {
286
+ for (const { name } of deps.agentDefs) {
287
+ const config = deps.agents[name];
288
+ const first = deps.effectiveModelArrays[name]?.[0];
289
+ const model = first?.id ?? config.model;
290
+ const variant = first ? first.variant : config.variant;
291
+ editor.update(name, (agent) => {
292
+ agent.description = config.description;
293
+ agent.system = config.prompt;
294
+ agent.mode = name === "orchestrator" ? "primary" : "subagent";
295
+ agent.hidden = config.hidden ?? false;
296
+ agent.color = /^#[0-9a-f]{6}$/i.test(config.color ?? "") ? config.color : ROLE_COLORS[name];
297
+ agent.steps = config.steps;
298
+ agent.permissions = translatePermissionRules(config.permission);
299
+ if (name !== "orchestrator") {
300
+ const overrides = deps.toolState.state.effective[name]?.overrides;
301
+ if (overrides?.length) {
302
+ agent.permissions.push(...appendOverrideRules(overrides));
303
+ }
304
+ }
305
+ const separator = model?.indexOf("/") ?? -1;
306
+ agent.model = model && separator > 0 && separator < model.length - 1 ? {
307
+ providerID: model.slice(0, separator),
308
+ id: model.slice(separator + 1),
309
+ ...variant ? { variant } : {}
310
+ } : void 0;
311
+ });
312
+ }
313
+ editor.default("orchestrator");
314
+ editor.remove("explore");
315
+ editor.remove("general");
316
+ });
317
+ }
26
318
 
27
319
  // src/harness/opencode-init-command.ts
28
320
  import { existsSync, readFileSync } from "fs";
@@ -62,664 +354,224 @@ ${contract}`
62
354
  };
63
355
  }
64
356
 
65
- // src/utils/logger.ts
66
- import { createRequire } from "module";
67
- var require2 = createRequire(import.meta.url);
68
- function getLogFile() {
69
- const os2 = require2("node:os");
70
- const path3 = require2("node:path");
71
- const configuredPath = process.env.THOTH_AGENTS_LOG_FILE?.trim();
72
- return configuredPath || path3.join(os2.tmpdir(), "thoth-agents.log");
73
- }
74
- function log(message, data) {
75
- try {
76
- const fs2 = require2("node:fs");
77
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
78
- const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}
79
- `;
80
- fs2.appendFileSync(getLogFile(), logEntry);
81
- } catch {
82
- }
83
- }
84
-
85
- // src/hooks/auto-update-checker/checker.ts
86
- import * as fs from "fs";
87
- import * as path2 from "path";
88
- import { fileURLToPath } from "url";
89
-
90
- // src/hooks/auto-update-checker/constants.ts
91
- import * as os from "os";
92
- import * as path from "path";
93
- var PACKAGE_NAME = "thoth-agents";
94
- var NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
95
- var NPM_FETCH_TIMEOUT = 5e3;
96
- function getCacheDir() {
97
- if (process.platform === "win32") {
98
- return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode");
99
- }
100
- return path.join(os.homedir(), ".cache", "opencode");
101
- }
102
- var CACHE_DIR = getCacheDir();
103
- var INSTALLED_PACKAGE_JSON = path.join(
104
- CACHE_DIR,
105
- "node_modules",
106
- PACKAGE_NAME,
107
- "package.json"
108
- );
109
- var configPaths = getOpenCodeConfigPaths();
110
- var USER_OPENCODE_CONFIG = configPaths[0];
111
- var USER_OPENCODE_CONFIG_JSONC = configPaths[1];
357
+ // src/opencode/command.ts
358
+ async function registerCommand(ctx, deps) {
359
+ const command = createOpenCodeInitCommand({
360
+ projectRoot: deps.directory,
361
+ packageRoot: deps.packageRoot
362
+ });
363
+ await ctx.command.transform((editor) => {
364
+ editor.add({
365
+ name: "thoth-init",
366
+ description: command.description,
367
+ async execute({ sessionID, prompt, delivery }) {
368
+ const args = prompt.text.trim();
369
+ const text = command.template + (args ? `
112
370
 
113
- // src/hooks/auto-update-checker/checker.ts
114
- function isPrereleaseVersion(version) {
115
- return version.includes("-");
116
- }
117
- function isDistTag(version) {
118
- return !/^\d/.test(version);
119
- }
120
- function extractChannel(version) {
121
- if (!version) return "latest";
122
- if (isDistTag(version)) return version;
123
- if (isPrereleaseVersion(version)) {
124
- const prereleasePart = version.split("-")[1];
125
- if (prereleasePart) {
126
- const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/);
127
- if (channelMatch) return channelMatch[1];
128
- }
129
- }
130
- return "latest";
131
- }
132
- function getConfigPaths(directory) {
133
- return [
134
- path2.join(directory, ".opencode", "opencode.json"),
135
- path2.join(directory, ".opencode", "opencode.jsonc"),
136
- USER_OPENCODE_CONFIG,
137
- USER_OPENCODE_CONFIG_JSONC
138
- ];
139
- }
140
- function getLocalDevPath(directory) {
141
- for (const configPath of getConfigPaths(directory)) {
142
- try {
143
- if (!fs.existsSync(configPath)) continue;
144
- const content = fs.readFileSync(configPath, "utf-8");
145
- const config = JSON.parse(stripJsonComments(content));
146
- const plugins = config.plugin ?? [];
147
- for (const entry of plugins) {
148
- if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
149
- try {
150
- return fileURLToPath(entry);
151
- } catch {
152
- return entry.replace("file://", "");
153
- }
154
- }
155
- }
156
- } catch {
157
- }
158
- }
159
- return null;
160
- }
161
- function findPackageJsonUp(startPath) {
162
- try {
163
- const stat = fs.statSync(startPath);
164
- let dir = stat.isDirectory() ? startPath : path2.dirname(startPath);
165
- for (let i = 0; i < 10; i++) {
166
- const pkgPath = path2.join(dir, "package.json");
167
- if (fs.existsSync(pkgPath)) {
168
- try {
169
- const content = fs.readFileSync(pkgPath, "utf-8");
170
- const pkg = JSON.parse(content);
171
- if (pkg.name === PACKAGE_NAME) return pkgPath;
172
- } catch {
173
- }
371
+ ${args}` : "");
372
+ await ctx.session.switchAgent({ sessionID, agent: "orchestrator" });
373
+ await ctx.session.prompt({ sessionID, text, delivery });
174
374
  }
175
- const parent = path2.dirname(dir);
176
- if (parent === dir) break;
177
- dir = parent;
178
- }
179
- } catch {
180
- }
181
- return null;
375
+ });
376
+ });
182
377
  }
183
- function getLocalDevVersion(directory) {
184
- const localPath = getLocalDevPath(directory);
185
- if (!localPath) return null;
186
- try {
187
- const pkgPath = findPackageJsonUp(localPath);
188
- if (!pkgPath) return null;
189
- const content = fs.readFileSync(pkgPath, "utf-8");
190
- const pkg = JSON.parse(content);
191
- return pkg.version ?? null;
192
- } catch {
193
- return null;
378
+
379
+ // src/opencode/context.ts
380
+ import { fileURLToPath } from "url";
381
+ function loadOpenCodeDependencies(ctx) {
382
+ const directory = ctx.location.directory;
383
+ const config = loadPluginConfig(directory);
384
+ const agentDefs = createAgents(config);
385
+ const agents = renderOpenCodeAgentConfigs(config);
386
+ const modelArrayMap = {};
387
+ const runtimeChains = {};
388
+ const effectiveModelArrays = {};
389
+ for (const agent of agentDefs) {
390
+ if (!agent._modelArray?.length) continue;
391
+ modelArrayMap[agent.name] = [...agent._modelArray];
392
+ effectiveModelArrays[agent.name] = [...agent._modelArray];
393
+ runtimeChains[agent.name] = agent._modelArray.map((model) => model.id);
194
394
  }
195
- }
196
- function findPluginEntry(directory) {
197
- for (const configPath of getConfigPaths(directory)) {
198
- try {
199
- if (!fs.existsSync(configPath)) continue;
200
- const content = fs.readFileSync(configPath, "utf-8");
201
- const config = JSON.parse(stripJsonComments(content));
202
- const plugins = config.plugin ?? [];
203
- for (const entry of plugins) {
204
- if (entry === PACKAGE_NAME) {
205
- return { entry, isPinned: false, pinnedVersion: null, configPath };
206
- }
207
- if (entry.startsWith(`${PACKAGE_NAME}@`)) {
208
- const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
209
- const isPinned = pinnedVersion !== "latest";
210
- return {
211
- entry,
212
- isPinned,
213
- pinnedVersion: isPinned ? pinnedVersion : null,
214
- configPath
215
- };
216
- }
217
- }
218
- } catch {
395
+ if (config.fallback?.enabled !== false) {
396
+ for (const [name, models] of Object.entries(
397
+ config.fallback?.chains ?? {}
398
+ )) {
399
+ if (!models?.length) continue;
400
+ const currentModel = agents[name]?.model;
401
+ const effective = effectiveModelArrays[name] ?? (currentModel ? [{ id: currentModel, variant: agents[name]?.variant }] : []);
402
+ for (const id of models) {
403
+ if (!effective.some((model) => model.id === id)) effective.push({ id });
404
+ }
405
+ runtimeChains[name] = effective.map((model) => model.id);
406
+ effectiveModelArrays[name] = effective;
407
+ }
408
+ }
409
+ const roster = {};
410
+ const configuredChains = {};
411
+ const baseArrays = structuredClone(effectiveModelArrays);
412
+ const baseRuntimeChains = structuredClone(runtimeChains);
413
+ for (const { name } of agentDefs) {
414
+ const role = name;
415
+ const first = effectiveModelArrays[name]?.[0];
416
+ roster[role] = first ? { model: first.id, variant: first.variant } : { model: agents[name]?.model, variant: agents[name]?.variant };
417
+ configuredChains[name] = effectiveModelArrays[name] ?? (agents[name]?.model ? [{ id: agents[name].model, variant: agents[name].variant }] : []);
418
+ }
419
+ const store = new ModelProfileStore({
420
+ cwd: directory,
421
+ roster,
422
+ warn: (message) => console.warn(`[thoth-agents] ${message}`)
423
+ });
424
+ const profileState = {
425
+ store,
426
+ roster,
427
+ selection: store.resolve(),
428
+ roles: {},
429
+ refresh() {
430
+ this.selection = store.resolve();
431
+ applyProfile();
219
432
  }
220
- }
221
- return null;
222
- }
223
- var cachedPackageVersion = null;
224
- function getCachedVersion() {
225
- if (cachedPackageVersion) return cachedPackageVersion;
226
- try {
227
- if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
228
- const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8");
229
- const pkg = JSON.parse(content);
230
- if (pkg.version) {
231
- cachedPackageVersion = pkg.version;
232
- return pkg.version;
433
+ };
434
+ function applyProfile() {
435
+ for (const { name } of agentDefs) {
436
+ const role = name;
437
+ profileState.roles[role] = resolveRoleProfile(
438
+ role,
439
+ profileState.selection.profile,
440
+ roster
441
+ );
442
+ if (profileState.selection.name === "default") {
443
+ if (baseArrays[name])
444
+ effectiveModelArrays[name] = structuredClone(baseArrays[name]);
445
+ else delete effectiveModelArrays[name];
446
+ if (baseRuntimeChains[name])
447
+ runtimeChains[name] = [...baseRuntimeChains[name]];
448
+ else delete runtimeChains[name];
449
+ } else {
450
+ const chain = mergeProfileModelChain(
451
+ profileState.roles[role],
452
+ configuredChains[name]
453
+ );
454
+ effectiveModelArrays[name] = chain;
455
+ runtimeChains[name] = chain.map(({ id }) => id);
233
456
  }
234
457
  }
235
- } catch {
236
458
  }
237
- try {
238
- const currentDir = path2.dirname(fileURLToPath(import.meta.url));
239
- const pkgPath = findPackageJsonUp(currentDir);
240
- if (pkgPath) {
241
- const content = fs.readFileSync(pkgPath, "utf-8");
242
- const pkg = JSON.parse(content);
243
- if (pkg.version) {
244
- cachedPackageVersion = pkg.version;
245
- return pkg.version;
246
- }
459
+ applyProfile();
460
+ const toolStore = new ToolsOverrideStore({
461
+ cwd: directory,
462
+ warn: (message) => console.warn(`[thoth-agents] ${message}`)
463
+ });
464
+ const toolState = {
465
+ store: toolStore,
466
+ state: toolStore.resolve(),
467
+ refresh() {
468
+ this.state = toolStore.resolve();
247
469
  }
248
- } catch (err) {
249
- log(
250
- "[auto-update-checker] Failed to resolve version from current directory:",
251
- err
252
- );
253
- }
254
- return null;
255
- }
256
- async function getLatestVersion(channel = "latest") {
257
- const controller = new AbortController();
258
- const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
259
- try {
260
- const response = await fetch(NPM_REGISTRY_URL, {
261
- signal: controller.signal,
262
- headers: { Accept: "application/json" }
263
- });
264
- if (!response.ok) return null;
265
- const data = await response.json();
266
- return data[channel] ?? data.latest ?? null;
267
- } catch {
268
- return null;
269
- } finally {
270
- clearTimeout(timeoutId);
271
- }
272
- }
273
-
274
- // src/hooks/auto-update-checker/index.ts
275
- function createAutoUpdateCheckerHook(ctx, options = {}) {
276
- const { showStartupToast = true } = options;
277
- let hasChecked = false;
470
+ };
278
471
  return {
279
- event: ({ event }) => {
280
- if (event.type !== "session.created") return;
281
- if (hasChecked) return;
282
- const props = event.properties;
283
- if (props?.info?.parentID) return;
284
- hasChecked = true;
285
- setTimeout(async () => {
286
- const cachedVersion = getCachedVersion();
287
- const localDevVersion = getLocalDevVersion(ctx.directory);
288
- const displayVersion = localDevVersion ?? cachedVersion;
289
- if (localDevVersion) {
290
- if (showStartupToast) {
291
- showToast(
292
- ctx,
293
- `thoth-agents ${displayVersion} (dev)`,
294
- "Running in local development mode.",
295
- "info"
296
- );
297
- }
298
- log("[auto-update-checker] Local development mode");
299
- return;
300
- }
301
- if (showStartupToast) {
302
- showToast(
303
- ctx,
304
- `thoth-agents ${displayVersion ?? "unknown"}`,
305
- "thoth-agents is active.",
306
- "info"
307
- );
308
- }
309
- runBackgroundUpdateCheck(ctx).catch((err) => {
310
- log("[auto-update-checker] Background update check failed:", err);
311
- });
312
- }, 0);
313
- }
472
+ directory,
473
+ // Both src/opencode/context.ts and dist/index.js (or a root chunk) resolve
474
+ // the package root here; nested source modules need one extra parent.
475
+ packageRoot: fileURLToPath(
476
+ new URL(
477
+ import.meta.url.endsWith(".ts") ? "../.." : "..",
478
+ import.meta.url
479
+ )
480
+ ),
481
+ config,
482
+ agentDefs,
483
+ agents,
484
+ modelArrayMap,
485
+ effectiveModelArrays,
486
+ runtimeChains,
487
+ profileState,
488
+ toolState
314
489
  };
315
490
  }
316
- async function runBackgroundUpdateCheck(ctx) {
317
- const pluginInfo = findPluginEntry(ctx.directory);
318
- if (!pluginInfo) {
319
- log("[auto-update-checker] Plugin not found in config");
320
- return;
321
- }
322
- const cachedVersion = getCachedVersion();
323
- const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
324
- if (!currentVersion) {
325
- log("[auto-update-checker] No version found (cached or pinned)");
326
- return;
327
- }
328
- const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion);
329
- const latestVersion = await getLatestVersion(channel);
330
- if (!latestVersion) {
331
- log(
332
- "[auto-update-checker] Failed to fetch latest version for channel:",
333
- channel
334
- );
335
- return;
336
- }
337
- if (currentVersion === latestVersion) {
338
- log(
339
- "[auto-update-checker] Already on latest version for channel:",
340
- channel
341
- );
342
- return;
343
- }
344
- log(
345
- `[auto-update-checker] Update available (${channel}): ${currentVersion} \u2192 ${latestVersion}`
346
- );
347
- showToast(
348
- ctx,
349
- `thoth-agents ${latestVersion} available`,
350
- `v${currentVersion} \u2192 v${latestVersion}. Run npx thoth-agents@latest install --agent=opencode or use interactive CLI Update.`,
351
- "info",
352
- 1e4
353
- );
354
- log("[auto-update-checker] Notification only; explicit CLI update required");
355
- }
356
- function showToast(ctx, title, message, variant = "info", duration = 3e3) {
357
- ctx.client.tui.showToast({
358
- body: { title, message, variant, duration }
359
- }).catch(() => {
360
- });
361
- }
362
491
 
363
492
  // src/hooks/delegate-task-retry/patterns.ts
364
493
  var DELEGATE_TASK_ERROR_PATTERNS = [
365
494
  {
366
- pattern: "load_skills",
367
- errorType: "missing_load_skills",
368
- fixHint: "Add load_skills=[] (empty array when no skill is needed)."
369
- },
370
- {
371
- pattern: "category OR subagent_type",
372
- errorType: "mutual_exclusion",
373
- fixHint: 'Provide only one: category (e.g., "unspecified-low") OR subagent_type (e.g., "explorer").'
374
- },
375
- {
376
- pattern: "Must provide either category or subagent_type",
377
- errorType: "missing_category_or_agent",
378
- fixHint: 'Add either category="unspecified-low" or subagent_type="explorer".'
495
+ pattern: "Unknown agent:",
496
+ errorType: "unknown_agent",
497
+ fixHint: "Use an available agent name in the agent argument."
379
498
  },
380
499
  {
381
- pattern: "Unknown category",
382
- errorType: "unknown_category",
383
- fixHint: "Use a valid category listed in the error output."
500
+ pattern: "cannot run as a subagent",
501
+ errorType: "primary_agent",
502
+ fixHint: "Choose a specialist agent, not a primary agent."
384
503
  },
385
504
  {
386
- pattern: "Unknown agent",
387
- errorType: "unknown_agent",
388
- fixHint: "Use a valid agent name from the available list."
505
+ pattern: "Subagent depth limit reached",
506
+ errorType: "depth_limit",
507
+ fixHint: "Return to the root coordinator; nesting depth is 1. Do not retry nested delegation."
389
508
  },
390
509
  {
391
- pattern: "Skills not found",
392
- errorType: "unknown_skills",
393
- fixHint: "Use valid skill names listed in the error output."
510
+ pattern: "Subagent denied:",
511
+ errorType: "permission_denied",
512
+ fixHint: "Return the permission blocker to the root coordinator; do not bypass the denial."
394
513
  },
395
514
  {
396
- pattern: "is not allowed. Allowed agents:",
397
- errorType: "background_agent_not_allowed",
398
- fixHint: "Use one of the allowed agents shown in the error or delegate from a parent agent that can call this subagent."
515
+ pattern: "Invalid arguments",
516
+ errorType: "invalid_arguments",
517
+ fixHint: "Supply agent, description and prompt; optional model, sessionID and background must have the native types."
399
518
  }
400
519
  ];
401
520
  function detectDelegateTaskError(output) {
402
- if (!output || typeof output !== "string") return null;
403
- const hasErrorSignal = output.includes("[ERROR]") || output.includes("Invalid arguments") || output.includes("is not allowed. Allowed agents:");
404
- if (!hasErrorSignal) return null;
405
- for (const pattern of DELEGATE_TASK_ERROR_PATTERNS) {
406
- if (output.includes(pattern.pattern)) {
407
- return {
408
- errorType: pattern.errorType,
409
- originalOutput: output
410
- };
411
- }
412
- }
413
- return null;
521
+ const pattern = DELEGATE_TASK_ERROR_PATTERNS.find(
522
+ (entry) => output.includes(entry.pattern)
523
+ );
524
+ return pattern ? { errorType: pattern.errorType, originalOutput: output } : null;
414
525
  }
415
526
 
416
527
  // src/hooks/delegate-task-retry/guidance.ts
417
- function extractAvailableList(output) {
418
- const match = output.match(/Allowed agents:\s*(.+)$/m);
419
- if (match) return match[1].trim();
420
- const available = output.match(/Available[^:]*:\s*(.+)$/m);
421
- if (available) return available[1].trim();
422
- return null;
423
- }
424
528
  function buildRetryGuidance(errorInfo) {
425
529
  const pattern = DELEGATE_TASK_ERROR_PATTERNS.find(
426
- (p) => p.errorType === errorInfo.errorType
530
+ (entry) => entry.errorType === errorInfo.errorType
427
531
  );
428
- if (!pattern) {
429
- return "\n[delegate-task retry] Fix parameters and retry with corrected arguments.";
430
- }
431
- const available = extractAvailableList(errorInfo.originalOutput);
432
532
  const lines = [
433
- "",
434
- "[delegate-task retry suggestion]",
533
+ "[subagent retry suggestion]",
435
534
  `Error type: ${errorInfo.errorType}`,
436
- `Fix: ${pattern.fixHint}`
535
+ `Fix: ${pattern?.fixHint ?? "Correct the native subagent arguments before retrying."}`
437
536
  ];
438
- if (available) {
439
- lines.push(`Available: ${available}`);
440
- }
441
- if (errorInfo.errorType === "background_agent_not_allowed") {
537
+ if (errorInfo.errorType !== "depth_limit" && errorInfo.errorType !== "permission_denied") {
442
538
  lines.push(
443
- "Retry now with corrected parameters.",
444
- "Use `background=true` only for `explorer` or `librarian`; otherwise retry without background.",
445
- "If the host enables `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`, collect results with `task_status` before reporting completion.",
446
- "Examples:",
447
- 'task(description="...", prompt="...", subagent_type="explorer", background=true)',
448
- 'task_status(task_id="ses_...", wait=true)'
539
+ 'Example: subagent({agent:"explorer", description:"Locate entrypoint", prompt:"Find the bounded entrypoint"})',
540
+ "Use background:true for independent work; completion arrives automatically. Use sessionID to clarify or continue the same child, including while running. Do not poll."
449
541
  );
450
- return lines.join("\n");
451
542
  }
452
- lines.push(
453
- "Retry now with corrected parameters. Example:",
454
- 'task(description="...", prompt="...", subagent_type="explorer")'
455
- );
456
543
  return lines.join("\n");
457
544
  }
458
545
 
459
546
  // src/hooks/delegate-task-retry/hook.ts
460
- function createDelegateTaskRetryHook(_ctx) {
461
- return {
462
- "tool.execute.after": async (input, output) => {
463
- const toolName = input.tool.toLowerCase();
464
- const isDelegateTool = toolName === "task";
465
- if (!isDelegateTool) return;
466
- if (typeof output.output !== "string") return;
467
- const detected = detectDelegateTaskError(output.output);
468
- if (!detected) return;
469
- output.output += `
470
- ${buildRetryGuidance(detected)}`;
471
- }
547
+ function appendErrorGuidance(event, guidance) {
548
+ const properties = Object.getOwnPropertyDescriptors(event.error);
549
+ properties.message = {
550
+ value: `${event.error.message}
551
+ ${guidance}`,
552
+ configurable: true,
553
+ writable: true,
554
+ enumerable: true
555
+ };
556
+ properties.stack = {
557
+ value: event.error.stack,
558
+ configurable: true,
559
+ writable: true,
560
+ enumerable: false
561
+ };
562
+ event.error = Object.create(Object.getPrototypeOf(event.error), properties);
563
+ }
564
+ function createDelegateTaskRetryHook() {
565
+ return async (event) => {
566
+ if (event.tool !== "subagent" || event.status !== "error") return;
567
+ if (event.error.message.includes("[subagent retry suggestion]")) return;
568
+ const detected = detectDelegateTaskError(event.error.message);
569
+ if (!detected) return;
570
+ appendErrorGuidance(event, buildRetryGuidance(detected));
472
571
  };
473
572
  }
474
573
 
475
- // src/hooks/foreground-fallback/index.ts
476
- var RATE_LIMIT_PATTERNS = [
477
- /\b429\b/,
478
- /rate.?limit/i,
479
- /too many requests/i,
480
- /quota.?exceeded/i,
481
- /usage.?exceeded/i,
482
- /usage limit/i,
483
- /overloaded/i,
484
- /resource.?exhausted/i,
485
- /insufficient.?quota/i,
486
- /high concurrency/i,
487
- /reduce concurrency/i
488
- ];
489
- function isRateLimitError(error) {
490
- if (!error || typeof error !== "object") return false;
491
- const err = error;
492
- const text = [
493
- err.message ?? "",
494
- String(err.data?.statusCode ?? ""),
495
- err.data?.message ?? "",
496
- err.data?.responseBody ?? ""
497
- ].join(" ");
498
- return RATE_LIMIT_PATTERNS.some((p) => p.test(text));
499
- }
500
- function parseModel(model) {
501
- const slash = model.indexOf("/");
502
- if (slash <= 0 || slash >= model.length - 1) return null;
503
- return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
504
- }
505
- var DEDUP_WINDOW_MS = 5e3;
506
- var ForegroundFallbackManager = class {
507
- constructor(client, chains, enabled) {
508
- this.client = client;
509
- this.chains = chains;
510
- this.enabled = enabled;
511
- }
512
- client;
513
- chains;
514
- enabled;
515
- /** sessionID → last observed model string ("providerID/modelID") */
516
- sessionModel = /* @__PURE__ */ new Map();
517
- /** sessionID → agent name (populated from message.updated info.agent field) */
518
- sessionAgent = /* @__PURE__ */ new Map();
519
- /** sessionID → set of models already attempted this session */
520
- sessionTried = /* @__PURE__ */ new Map();
521
- /** Sessions with an active fallback switch in flight */
522
- inProgress = /* @__PURE__ */ new Set();
523
- /** sessionID → timestamp of last trigger (for deduplication) */
524
- lastTrigger = /* @__PURE__ */ new Map();
525
- /**
526
- * Process an OpenCode plugin event.
527
- * Call this from the plugin's `event` hook for every event received.
528
- */
529
- async handleEvent(rawEvent) {
530
- if (!this.enabled) return;
531
- const event = rawEvent;
532
- if (!event?.type) return;
533
- switch (event.type) {
534
- case "message.updated": {
535
- const info = event.properties?.info;
536
- if (!info) break;
537
- const sessionID = info.sessionID;
538
- if (!sessionID) break;
539
- if (typeof info.agent === "string") {
540
- this.sessionAgent.set(sessionID, info.agent);
541
- }
542
- if (typeof info.providerID === "string" && typeof info.modelID === "string") {
543
- this.sessionModel.set(
544
- sessionID,
545
- `${info.providerID}/${info.modelID}`
546
- );
547
- }
548
- if (info.error && isRateLimitError(info.error)) {
549
- await this.tryFallback(sessionID);
550
- }
551
- break;
552
- }
553
- case "session.error": {
554
- const props = event.properties;
555
- if (props?.sessionID && props.error && isRateLimitError(props.error)) {
556
- await this.tryFallback(props.sessionID);
557
- }
558
- break;
559
- }
560
- case "session.status": {
561
- const props = event.properties;
562
- if (!props?.sessionID || props.status?.type !== "retry") break;
563
- const msg = props.status.message?.toLowerCase() ?? "";
564
- if (msg.includes("rate limit") || msg.includes("usage limit") || msg.includes("usage exceeded") || msg.includes("quota exceeded") || msg.includes("high concurrency") || msg.includes("reduce concurrency")) {
565
- await this.tryFallback(props.sessionID);
566
- }
567
- break;
568
- }
569
- case "subagent.session.created": {
570
- const props = event.properties;
571
- if (props?.sessionID && typeof props.agentName === "string") {
572
- this.sessionAgent.set(props.sessionID, props.agentName);
573
- }
574
- break;
575
- }
576
- case "session.deleted": {
577
- const props = event.properties;
578
- const id = props?.info?.id ?? props?.sessionID;
579
- if (id) {
580
- this.sessionModel.delete(id);
581
- this.sessionAgent.delete(id);
582
- this.sessionTried.delete(id);
583
- this.inProgress.delete(id);
584
- this.lastTrigger.delete(id);
585
- }
586
- break;
587
- }
588
- }
589
- }
590
- getSessionModel(sessionId) {
591
- return this.sessionModel.get(sessionId);
592
- }
593
- // ---------------------------------------------------------------------------
594
- // Core fallback logic
595
- // ---------------------------------------------------------------------------
596
- async tryFallback(sessionID) {
597
- if (!sessionID) return;
598
- if (this.inProgress.has(sessionID)) return;
599
- const now = Date.now();
600
- if (now - (this.lastTrigger.get(sessionID) ?? 0) < DEDUP_WINDOW_MS) return;
601
- this.lastTrigger.set(sessionID, now);
602
- this.inProgress.add(sessionID);
603
- try {
604
- const currentModel = this.sessionModel.get(sessionID);
605
- const agentName = this.sessionAgent.get(sessionID);
606
- const chain = this.resolveChain(agentName, currentModel);
607
- if (!chain.length) {
608
- log("[foreground-fallback] no chain configured", {
609
- sessionID,
610
- agentName
611
- });
612
- return;
613
- }
614
- if (!this.sessionTried.has(sessionID)) {
615
- this.sessionTried.set(sessionID, /* @__PURE__ */ new Set());
616
- }
617
- const tried = this.sessionTried.get(sessionID);
618
- if (currentModel) tried.add(currentModel);
619
- const nextModel = chain.find((m) => !tried.has(m));
620
- if (!nextModel) {
621
- log("[foreground-fallback] fallback chain exhausted", {
622
- sessionID,
623
- agentName,
624
- tried: [...tried]
625
- });
626
- return;
627
- }
628
- tried.add(nextModel);
629
- const ref = parseModel(nextModel);
630
- if (!ref) {
631
- log("[foreground-fallback] invalid model format", {
632
- sessionID,
633
- nextModel
634
- });
635
- return;
636
- }
637
- const result = await this.client.session.messages({
638
- path: { id: sessionID }
639
- });
640
- const messages = result.data ?? [];
641
- const lastUser = [...messages].reverse().find((m) => m.info.role === "user");
642
- if (!lastUser) {
643
- log("[foreground-fallback] no user message found", { sessionID });
644
- return;
645
- }
646
- try {
647
- await this.client.session.abort({ path: { id: sessionID } });
648
- } catch {
649
- }
650
- await new Promise((r) => setTimeout(r, 500));
651
- await this.client.session.promptAsync({
652
- path: { id: sessionID },
653
- body: {
654
- // biome-ignore lint/suspicious/noExplicitAny: parts array from message history
655
- parts: lastUser.parts,
656
- model: ref
657
- }
658
- });
659
- this.sessionModel.set(sessionID, nextModel);
660
- log("[foreground-fallback] switched to fallback model", {
661
- sessionID,
662
- agentName,
663
- from: currentModel,
664
- to: nextModel
665
- });
666
- } catch (err) {
667
- log("[foreground-fallback] fallback attempt failed", {
668
- sessionID,
669
- error: err instanceof Error ? err.message : String(err)
670
- });
671
- } finally {
672
- this.inProgress.delete(sessionID);
673
- }
674
- }
675
- // ---------------------------------------------------------------------------
676
- // Chain resolution
677
- // ---------------------------------------------------------------------------
678
- /**
679
- * Determine the fallback chain to use for a session.
680
- *
681
- * Priority:
682
- * 1. Agent name known AND has a configured chain → return it directly
683
- * 2. Agent name known but NO chain configured → return [] (no fallback;
684
- * do NOT bleed into other agents' chains which would re-prompt the
685
- * session with a model belonging to a completely different agent)
686
- * 3. Agent name unknown, current model known → search all chains for
687
- * the model to infer which chain to use
688
- * 4. Nothing matches → flatten all chains as a last resort (only
689
- * reached when both agent name and current model are unavailable)
690
- */
691
- resolveChain(agentName, currentModel) {
692
- if (agentName) {
693
- return this.chains[agentName] ?? [];
694
- }
695
- if (currentModel) {
696
- for (const chain of Object.values(this.chains)) {
697
- if (chain.includes(currentModel)) return chain;
698
- }
699
- }
700
- const all = [];
701
- const seen = /* @__PURE__ */ new Set();
702
- for (const chain of Object.values(this.chains)) {
703
- for (const m of chain) {
704
- if (!seen.has(m)) {
705
- seen.add(m);
706
- all.push(m);
707
- }
708
- }
709
- }
710
- return all;
711
- }
712
- };
713
-
714
574
  // src/hooks/json-error-recovery/hook.ts
715
- var JSON_ERROR_TOOL_EXCLUDE_LIST = [
716
- "bash",
717
- "read",
718
- "glob",
719
- "webfetch",
720
- "grep_app_searchgithub",
721
- "exa_web_search_exa"
722
- ];
723
575
  var JSON_ERROR_PATTERNS = [
724
576
  /json parse error/i,
725
577
  /failed to parse json/i,
@@ -731,7 +583,6 @@ var JSON_ERROR_PATTERNS = [
731
583
  /json[^\n]*unexpected eof/i
732
584
  ];
733
585
  var JSON_ERROR_REMINDER_MARKER = "[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]";
734
- var JSON_ERROR_EXCLUDED_TOOLS = new Set(JSON_ERROR_TOOL_EXCLUDE_LIST);
735
586
  var JSON_ERROR_REMINDER = `
736
587
  [JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]
737
588
 
@@ -744,56 +595,74 @@ STOP and do this NOW:
744
595
 
745
596
  DO NOT repeat the exact same invalid call.
746
597
  `;
747
- function createJsonErrorRecoveryHook(_ctx) {
748
- return {
749
- "tool.execute.after": async (input, output) => {
750
- if (JSON_ERROR_EXCLUDED_TOOLS.has(input.tool.toLowerCase())) return;
751
- if (typeof output.output !== "string") return;
752
- if (output.output.includes(JSON_ERROR_REMINDER_MARKER)) return;
753
- const outputText = output.output;
754
- const hasJsonError = JSON_ERROR_PATTERNS.some(
755
- (pattern) => pattern.test(outputText)
756
- );
757
- if (hasJsonError) {
758
- output.output += `
759
- ${JSON_ERROR_REMINDER}`;
760
- }
761
- }
598
+ function createJsonErrorRecoveryHook() {
599
+ return async (event) => {
600
+ if (event.tool !== "subagent" || event.status !== "error") return;
601
+ const text = event.error.message;
602
+ if (text.includes(JSON_ERROR_REMINDER_MARKER)) return;
603
+ if (!JSON_ERROR_PATTERNS.some((pattern) => pattern.test(text))) return;
604
+ appendErrorGuidance(event, JSON_ERROR_REMINDER);
762
605
  };
763
606
  }
764
607
 
765
608
  // src/hooks/oracle-gate-verdict/hook.ts
766
- var ORACLE_AGENT = getAgentRole("oracle").name;
767
609
  function createOracleGateVerdictHook(ctx) {
768
- return {
769
- "tool.execute.after": async (input, output) => {
770
- if (input.tool.toLowerCase() !== "task") return;
771
- if (input.args?.subagent_type !== ORACLE_AGENT) return;
772
- if (typeof output.output !== "string") return;
773
- const text = output.output;
774
- if (!text.includes("```thoth-result")) return;
775
- const opening = "<task_result>";
776
- const closing = "</task_result>";
777
- const start = text.indexOf(opening);
778
- const end = text.lastIndexOf(closing);
779
- const wrapped = start !== -1 && end >= start + opening.length;
780
- const child = wrapped ? text.slice(start + opening.length, end) : text;
781
- const summary = {
782
- ...summarizeGateResult(child, { cwd: ctx.directory }),
783
- agent: ORACLE_AGENT
784
- };
785
- const verdict = formatGateVerdict(summary);
786
- if (wrapped && text.slice(end + closing.length).endsWith(`
610
+ const delivered = /* @__PURE__ */ new WeakSet();
611
+ return async (event) => {
612
+ if (event.tool !== "subagent" || event.status !== "completed") return;
613
+ if (delivered.has(event.result)) return;
614
+ if (!event.input || typeof event.input !== "object" || !("agent" in event.input) || event.input.agent !== "oracle")
615
+ return;
616
+ const output = event.result.output;
617
+ if (!output || typeof output !== "object" || !("status" in output) || output.status !== "completed")
618
+ return;
619
+ const content = event.result.content;
620
+ if (content === void 0) return;
621
+ const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("");
622
+ const raw = "output" in output && typeof output.output === "string" && text.includes(output.output) ? output.output : void 0;
623
+ const message = raw ?? text;
624
+ if (!message.trim().split(/\r\n|\r|\n/).includes("```thoth-result"))
625
+ return;
626
+ const end = text.lastIndexOf("</subagent>");
627
+ const tail = end < 0 ? "" : text.slice(end + "</subagent>".length);
628
+ if (tail.split(/\r\n|\r|\n/).some((line) => line.startsWith("thoth-gate-verdict ")))
629
+ return;
630
+ if (Array.isArray(content) && content.some((part) => {
631
+ if (part.type !== "text") return false;
632
+ const lines = part.text.split(/\r\n|\r|\n/);
633
+ return lines.length === 3 && lines[0] === "" && lines[1] === "" && lines[2].startsWith("thoth-gate-verdict ");
634
+ }))
635
+ return;
636
+ const bare = raw === void 0 || !raw.trimStart().startsWith("<subagent ");
637
+ const summary = {
638
+ ...summarizeGateResult(bare ? message : "", { cwd: ctx.directory }),
639
+ agent: "oracle"
640
+ };
641
+ const verdict = formatGateVerdict(summary);
642
+ event.result = {
643
+ ...event.result,
644
+ content: typeof content === "string" ? `${content}
787
645
 
788
- ${verdict}`))
789
- return;
790
- output.output += `
646
+ ${verdict}` : [...content, { type: "text", text: `
791
647
 
792
- ${verdict}`;
793
- }
648
+ ${verdict}` }]
649
+ };
650
+ delivered.add(event.result);
794
651
  };
795
652
  }
796
653
 
654
+ // src/opencode/delegation-hooks.ts
655
+ async function registerDelegationHooks(ctx, deps) {
656
+ const retry = createDelegateTaskRetryHook();
657
+ const recover = createJsonErrorRecoveryHook();
658
+ const verdict = createOracleGateVerdictHook({ directory: deps.directory });
659
+ await ctx.tool.hook("execute.after", async (event) => {
660
+ await retry(event);
661
+ await recover(event);
662
+ await verdict(event);
663
+ });
664
+ }
665
+
797
666
  // src/mcp/index.ts
798
667
  function createAllBuiltinMcps() {
799
668
  return {
@@ -811,271 +680,941 @@ function createBuiltinMcps(disabledMcps = []) {
811
680
  );
812
681
  }
813
682
 
814
- // src/tools/ast-grep/tools.ts
815
- import { tool } from "@opencode-ai/plugin/tool";
683
+ // src/opencode/mcp.ts
684
+ async function registerMcp(ctx, deps) {
685
+ await ctx.mcp.transform((editor) => {
686
+ for (const [name, config] of Object.entries(
687
+ createBuiltinMcps(deps.config.disabled_mcps)
688
+ )) {
689
+ editor.set(name, config);
690
+ }
691
+ });
692
+ }
816
693
 
817
- // src/tools/ast-grep/cli.ts
818
- import { existsSync as existsSync5 } from "fs";
694
+ // src/opencode/permissions.ts
695
+ async function registerPermissions(ctx, deps) {
696
+ const specialists = new Set(
697
+ deps.agentDefs.map(({ name }) => name).filter((name) => name !== "orchestrator")
698
+ );
699
+ await ctx.permission.hook("evaluate", (event) => {
700
+ if (event.action !== "subagent" || !event.agent || !specialists.has(event.agent))
701
+ return;
702
+ event.effect = "deny";
703
+ event.message = `Thoth role "${event.agent}" cannot delegate: only orchestrator may call subagent.`;
704
+ });
705
+ }
819
706
 
820
- // src/tools/ast-grep/constants.ts
821
- import { spawnSync as spawnSync2 } from "child_process";
822
- import { existsSync as existsSync4, statSync as statSync2 } from "fs";
823
- import { createRequire as createRequire3 } from "module";
824
- import { dirname as dirname2, join as join5 } from "path";
707
+ // src/opencode/profile-rpc-handlers.ts
708
+ import { createHash as createHash2 } from "crypto";
709
+ import path2 from "path";
825
710
 
826
- // src/tools/ast-grep/downloader.ts
827
- import { chmodSync, existsSync as existsSync3, mkdirSync, unlinkSync } from "fs";
828
- import { createRequire as createRequire2 } from "module";
829
- import { homedir as homedir2 } from "os";
830
- import { join as join4 } from "path";
831
-
832
- // src/utils/tmux.ts
833
- var tmuxPath = null;
834
- var tmuxChecked = false;
835
- var storedConfig = null;
836
- var serverAvailable = null;
837
- var serverCheckUrl = null;
838
- async function isServerRunning(serverUrl) {
839
- if (serverCheckUrl === serverUrl && serverAvailable === true) {
840
- return true;
711
+ // src/opencode/profile-sessions.ts
712
+ var SessionProfileSelections = class {
713
+ constructor(storage, projectDir) {
714
+ this.storage = storage;
715
+ this.projectDir = projectDir;
716
+ }
717
+ storage;
718
+ projectDir;
719
+ parse(value) {
720
+ if (typeof value === "string")
721
+ return { name: value, projectDir: this.projectDir };
722
+ if (value && typeof value === "object" && "name" in value && "projectDir" in value && typeof value.name === "string" && typeof value.projectDir === "string")
723
+ return { name: value.name, projectDir: value.projectDir };
724
+ return void 0;
725
+ }
726
+ async set(sessionID, name, projectDir = this.projectDir) {
727
+ const key = `model-profiles/session/${sessionID}`;
728
+ if (name === void 0) await this.storage.remove(key);
729
+ else await this.storage.set(key, { name, projectDir });
730
+ }
731
+ /** Collect before mutating storage so pagination remains stable. */
732
+ async all() {
733
+ const prefix = "model-profiles/session/";
734
+ const selections = [];
735
+ let after;
736
+ do {
737
+ const page = await this.storage.scan({ prefix, after });
738
+ for (const entry of page.entries) {
739
+ const value = this.parse(entry.value);
740
+ if (entry.key.startsWith(prefix) && value)
741
+ selections.push({
742
+ ...value,
743
+ sessionID: entry.key.slice(prefix.length)
744
+ });
745
+ }
746
+ after = page.next;
747
+ } while (after !== void 0);
748
+ return selections;
749
+ }
750
+ async get(sessionID) {
751
+ return this.parse(
752
+ await this.storage.get(`model-profiles/session/${sessionID}`)
753
+ );
754
+ }
755
+ };
756
+
757
+ // src/opencode/profile-rpc-handlers.ts
758
+ function revision(profile) {
759
+ const canonical = profile === null ? null : {
760
+ agents: Object.fromEntries(
761
+ PROFILE_ROLES.filter((role) => profile.agents[role]).map((role) => [
762
+ role,
763
+ {
764
+ model: profile.agents[role]?.model,
765
+ variant: profile.agents[role]?.variant
766
+ }
767
+ ])
768
+ )
769
+ };
770
+ return createHash2("sha256").update(JSON.stringify(canonical)).digest("hex");
771
+ }
772
+ var RequestError = class extends Error {
773
+ constructor(code, message) {
774
+ super(message);
775
+ this.code = code;
776
+ }
777
+ code;
778
+ };
779
+ async function registerProfileRpc(ctx, deps) {
780
+ const { store, roster } = deps.profileState;
781
+ const sessions = new SessionProfileSelections(ctx.storage, deps.directory);
782
+ async function capture(sessionID) {
783
+ const session = await ctx.session.get({ sessionID });
784
+ const ref = session.model ?? (await ctx.agent.get({ agentID: session.agent ?? "orchestrator" })).data.model;
785
+ return ref ? {
786
+ model: `${ref.providerID}/${ref.id}`,
787
+ ...ref.variant ? { variant: ref.variant } : {}
788
+ } : {};
789
+ }
790
+ function snapshot(sessionName, change) {
791
+ const warnings = [];
792
+ const global = store.read("global", warnings);
793
+ const project = store.read("project", warnings);
794
+ const selections = {
795
+ ...sessionName === void 0 ? {} : { session: sessionName },
796
+ ...project.active === void 0 ? {} : { project: project.active },
797
+ ...global.active === void 0 ? {} : { global: global.active }
798
+ };
799
+ if (change) {
800
+ if (change.name === void 0) delete selections[change.scope];
801
+ else selections[change.scope] = change.name;
802
+ }
803
+ let effective = {
804
+ name: "default",
805
+ selectedBy: "default",
806
+ profile: store.defaultProfile()
807
+ };
808
+ for (const selectedBy of ["session", "project", "global"]) {
809
+ const name = selections[selectedBy];
810
+ if (!name) continue;
811
+ if (name === "default") {
812
+ effective = { name, selectedBy, profile: store.defaultProfile() };
813
+ break;
814
+ }
815
+ const source = Object.hasOwn(project.profiles, name) ? "project" : "global";
816
+ const profile = (source === "project" ? project : global).profiles[name];
817
+ if (Object.hasOwn(
818
+ (source === "project" ? project : global).profiles,
819
+ name
820
+ ) && (selectedBy !== "global" || Object.hasOwn(global.profiles, name))) {
821
+ effective = { name, source, selectedBy, profile };
822
+ break;
823
+ }
824
+ warnings.push(
825
+ `Missing ${selectedBy} model profile "${name}"; falling back.`
826
+ );
827
+ }
828
+ const profiles = [];
829
+ for (const [source, document] of [
830
+ ["global", global],
831
+ ["project", project]
832
+ ]) {
833
+ for (const [name, profile] of Object.entries(document.profiles))
834
+ profiles.push({
835
+ name,
836
+ scope: source,
837
+ source,
838
+ profile,
839
+ revision: revision(profile),
840
+ shadowed: source === "global" && Object.hasOwn(project.profiles, name)
841
+ });
842
+ }
843
+ return {
844
+ profiles,
845
+ defaultProfile: store.defaultProfile(),
846
+ roles: [...PROFILE_ROLES],
847
+ selections,
848
+ effective,
849
+ warnings
850
+ };
851
+ }
852
+ async function catalog() {
853
+ const [providers, models] = await Promise.all([
854
+ ctx.provider.list(),
855
+ ctx.model.list({ types: ["chat"] })
856
+ ]);
857
+ return providers.data.map((provider) => ({
858
+ id: provider.id,
859
+ name: provider.name,
860
+ models: models.data.filter((model) => model.providerID === provider.id && model.enabled).map((model) => ({
861
+ id: model.id,
862
+ name: model.name,
863
+ variants: model.variants.map((variant) => variant.id)
864
+ }))
865
+ }));
866
+ }
867
+ async function state(sessionID) {
868
+ return {
869
+ ...snapshot(
870
+ sessionID ? (await sessions.get(sessionID))?.name : void 0
871
+ ),
872
+ ...sessionID ? { orchestrator: await capture(sessionID) } : {},
873
+ providers: await catalog()
874
+ };
875
+ }
876
+ function available(profile, providers, name) {
877
+ for (const role of PROFILE_ROLES) {
878
+ const entry = resolveRoleProfile(role, profile, roster);
879
+ if (!entry.model) continue;
880
+ const slash = entry.model.indexOf("/");
881
+ const providerID = entry.model.slice(0, slash);
882
+ const modelID = entry.model.slice(slash + 1);
883
+ const model = providers.find((provider) => provider.id === providerID)?.models.find((candidate) => candidate.id === modelID);
884
+ if (!model || entry.variant && !model.variants.includes(entry.variant))
885
+ throw new RequestError(
886
+ "MODEL_UNAVAILABLE",
887
+ `Unavailable model or variant in profile "${name}" for ${role}: ${entry.model}${entry.variant ? `#${entry.variant}` : ""}. Nothing changed.`
888
+ );
889
+ }
890
+ }
891
+ async function activate(input) {
892
+ if (input.scope === "session" && !input.sessionID)
893
+ throw new RequestError(
894
+ "INVALID_REQUEST",
895
+ "Open a session to change its profile selection."
896
+ );
897
+ const previous = input.sessionID ? (await sessions.get(input.sessionID))?.name : void 0;
898
+ const providers = await catalog();
899
+ if (input.name !== void 0) {
900
+ const target = store.lookup(
901
+ input.name,
902
+ input.scope === "global" ? "global" : void 0
903
+ );
904
+ if (!target)
905
+ throw new RequestError(
906
+ "INVALID_REQUEST",
907
+ `Profile not found: ${input.name}`
908
+ );
909
+ available(target.profile, providers, input.name);
910
+ }
911
+ if (input.scope !== "session") {
912
+ const defaults = snapshot(void 0, input);
913
+ available(defaults.effective.profile, providers, defaults.effective.name);
914
+ }
915
+ const next = snapshot(previous, input);
916
+ available(next.effective.profile, providers, next.effective.name);
917
+ if (input.scope === "session" && input.sessionID)
918
+ await sessions.set(input.sessionID, input.name);
919
+ else {
920
+ if (input.scope === "global") store.setGlobalActive(input.name);
921
+ else store.setProjectPin(input.name);
922
+ deps.profileState.refresh();
923
+ await ctx.agent.reload();
924
+ }
925
+ const orchestrator = resolveRoleProfile(
926
+ "orchestrator",
927
+ next.effective.profile,
928
+ roster
929
+ );
930
+ if (input.sessionID && orchestrator.model) {
931
+ const slash = orchestrator.model.indexOf("/");
932
+ await ctx.session.switchModel({
933
+ sessionID: input.sessionID,
934
+ model: {
935
+ providerID: orchestrator.model.slice(0, slash),
936
+ id: orchestrator.model.slice(slash + 1),
937
+ ...orchestrator.variant ? { variant: orchestrator.variant } : {}
938
+ }
939
+ });
940
+ }
941
+ return state(input.sessionID);
942
+ }
943
+ let pending = Promise.resolve();
944
+ function handler(method, run) {
945
+ return (raw) => {
946
+ const operation = pending.then(async () => {
947
+ const parsed = profileRpc.methods[method].input.safeParse(raw);
948
+ if (!parsed.success)
949
+ return {
950
+ ok: false,
951
+ error: { code: "INVALID_REQUEST", message: parsed.error.message }
952
+ };
953
+ try {
954
+ return {
955
+ ok: true,
956
+ value: await run(parsed.data)
957
+ };
958
+ } catch (error) {
959
+ const code = error instanceof ProfileConflictError ? "PROFILE_CONFLICT" : error instanceof RequestError ? error.code : "OPERATION_FAILED";
960
+ return {
961
+ ok: false,
962
+ error: {
963
+ code,
964
+ message: error instanceof Error ? error.message : String(error)
965
+ }
966
+ };
967
+ }
968
+ });
969
+ pending = operation;
970
+ return operation;
971
+ };
972
+ }
973
+ function writable(name) {
974
+ try {
975
+ validateProfileName(name, true);
976
+ } catch (error) {
977
+ throw new RequestError("INVALID_REQUEST", String(error));
978
+ }
979
+ }
980
+ function current(scope, name) {
981
+ const document = store.read(scope);
982
+ return Object.hasOwn(document.profiles, name) ? document.profiles[name] : null;
983
+ }
984
+ function projectStore(projectDir) {
985
+ return projectDir === deps.directory ? store : new ModelProfileStore({
986
+ cwd: projectDir,
987
+ roster,
988
+ paths: {
989
+ global: store.paths.global,
990
+ project: path2.join(
991
+ projectDir,
992
+ ".opencode",
993
+ "thoth",
994
+ "profiles.json"
995
+ )
996
+ }
997
+ });
998
+ }
999
+ function identity(project, name) {
1000
+ const found = project.lookup(name);
1001
+ return found ? found.scope ? `${project.paths[found.scope]}:${name}` : "default" : void 0;
1002
+ }
1003
+ async function maintenance(scope, name, newName) {
1004
+ const selections = await sessions.all();
1005
+ const target = `${store.paths[scope]}:${name}`;
1006
+ const affected = selections.filter(
1007
+ (selection) => identity(projectStore(selection.projectDir), selection.name) === target
1008
+ );
1009
+ const projects = new Map(
1010
+ [
1011
+ .../* @__PURE__ */ new Set([deps.directory, ...selections.map((s) => s.projectDir)])
1012
+ ].map((dir) => [dir, projectStore(dir)])
1013
+ );
1014
+ const pins = [];
1015
+ for (const project of projects.values()) {
1016
+ const pin = project.read("project").active;
1017
+ if (pin && identity(project, pin) === target) pins.push(project);
1018
+ }
1019
+ if (newName !== void 0) {
1020
+ for (const [dir, project] of projects) {
1021
+ let after2 = function(pointer, globalPointer = false) {
1022
+ if (!pointer) return void 0;
1023
+ if (pointer === "default") return "default";
1024
+ if (globalPointer && !Object.hasOwn(global.profiles, pointer))
1025
+ return void 0;
1026
+ const source = Object.hasOwn(local.profiles, pointer) ? "project" : "global";
1027
+ if (!Object.hasOwn(
1028
+ (source === "project" ? local : global).profiles,
1029
+ pointer
1030
+ ))
1031
+ return void 0;
1032
+ return `${project.paths[source]}:${pointer}`;
1033
+ }, guard2 = function(before, next) {
1034
+ const expected = before === target ? `${store.paths[scope]}:${newName}` : before;
1035
+ if (next !== expected)
1036
+ throw new RequestError(
1037
+ "PROFILE_SHADOWED",
1038
+ `Renaming to "${newName}" collides with a shadowing profile in ${dir}. Nothing changed.`
1039
+ );
1040
+ };
1041
+ var after = after2, guard = guard2;
1042
+ const global = structuredClone(project.read("global"));
1043
+ const local = structuredClone(project.read("project"));
1044
+ const changed = scope === "global" ? global : project.paths.project === store.paths.project ? local : void 0;
1045
+ if (changed && Object.hasOwn(changed.profiles, name)) {
1046
+ changed.profiles[newName] = changed.profiles[name];
1047
+ delete changed.profiles[name];
1048
+ if (changed.active === name) changed.active = newName;
1049
+ }
1050
+ if (pins.includes(project)) local.active = newName;
1051
+ for (const selection of selections.filter(
1052
+ (s) => s.projectDir === dir
1053
+ )) {
1054
+ const before = identity(project, selection.name);
1055
+ guard2(before, after2(before === target ? newName : selection.name));
1056
+ }
1057
+ const pin = project.read("project").active;
1058
+ guard2(pin ? identity(project, pin) : void 0, after2(local.active));
1059
+ const active = project.read("global").active;
1060
+ const beforeGlobal = active && project.lookup(active, "global") ? identity(project, active) : void 0;
1061
+ guard2(beforeGlobal, after2(global.active, true));
1062
+ }
1063
+ }
1064
+ return { affected, pins };
1065
+ }
1066
+ const handlers = {
1067
+ state: handler("state", ({ sessionID }) => state(sessionID)),
1068
+ capture: handler("capture", ({ sessionID }) => capture(sessionID)),
1069
+ activate: handler("activate", activate),
1070
+ clear: handler("clear", activate),
1071
+ create: handler("create", ({ scope, name, profile }) => {
1072
+ writable(name);
1073
+ store.create(scope, name, profile);
1074
+ return { revision: revision(current(scope, name)) };
1075
+ }),
1076
+ save: handler("save", ({ scope, name, profile, baseRevision }) => {
1077
+ writable(name);
1078
+ const expected = current(scope, name);
1079
+ if (revision(expected) !== baseRevision)
1080
+ throw new ProfileConflictError(scope, name);
1081
+ store.save(scope, name, profile, { expected });
1082
+ return { revision: revision(current(scope, name)) };
1083
+ }),
1084
+ rename: handler("rename", async ({ scope, name, newName }) => {
1085
+ writable(name);
1086
+ writable(newName);
1087
+ const { affected, pins } = await maintenance(scope, name, newName);
1088
+ store.rename(scope, name, newName);
1089
+ for (const project of pins) project.setProjectPin(newName);
1090
+ for (const selection of affected)
1091
+ await sessions.set(selection.sessionID, newName, selection.projectDir);
1092
+ return {};
1093
+ }),
1094
+ delete: handler("delete", async ({ scope, name }) => {
1095
+ writable(name);
1096
+ const { affected, pins } = await maintenance(scope, name);
1097
+ store.delete(scope, name);
1098
+ for (const project of pins) project.setProjectPin();
1099
+ for (const selection of affected)
1100
+ await sessions.set(selection.sessionID, void 0);
1101
+ return {};
1102
+ })
1103
+ };
1104
+ await ctx.rpc.register(profileRpc, handlers);
1105
+ }
1106
+
1107
+ // src/opencode/profile-runtime.ts
1108
+ async function registerProfileRuntime(ctx, deps) {
1109
+ const sessions = new SessionProfileSelections(ctx.storage, deps.directory);
1110
+ await ctx.tool.hook("execute.before", async (event) => {
1111
+ if (event.tool !== "subagent") return;
1112
+ const input = event.input;
1113
+ if (!input || typeof input !== "object" || Array.isArray(input)) return;
1114
+ const args = input;
1115
+ if (args.model !== void 0) return;
1116
+ const role = PROFILE_ROLES.find((role2) => role2 === args.agent);
1117
+ if (!role) return;
1118
+ const { store, roster } = deps.profileState;
1119
+ const selection = store.resolve(
1120
+ (await sessions.get(event.sessionID))?.name
1121
+ );
1122
+ const model = formatModelString(
1123
+ resolveRoleProfile(role, selection.profile, roster)
1124
+ );
1125
+ if (model !== void 0) args.model = model;
1126
+ });
1127
+ }
1128
+
1129
+ // src/utils/logger.ts
1130
+ import { createRequire } from "module";
1131
+ var require2 = createRequire(import.meta.url);
1132
+ function getLogFile() {
1133
+ const os2 = require2("node:os");
1134
+ const path3 = require2("node:path");
1135
+ const configuredPath = process.env.THOTH_AGENTS_LOG_FILE?.trim();
1136
+ return configuredPath || path3.join(os2.tmpdir(), "thoth-agents.log");
1137
+ }
1138
+ function log(message, data) {
1139
+ try {
1140
+ const fs2 = require2("node:fs");
1141
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1142
+ const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}
1143
+ `;
1144
+ fs2.appendFileSync(getLogFile(), logEntry);
1145
+ } catch {
1146
+ }
1147
+ }
1148
+
1149
+ // src/hooks/foreground-fallback/index.ts
1150
+ var RETRYABLE = /* @__PURE__ */ new Set([
1151
+ "provider.rate-limit",
1152
+ "provider.quota",
1153
+ "provider.free-limit",
1154
+ "provider.chatgpt-limit",
1155
+ "provider.go-limit",
1156
+ "provider.budget-limit",
1157
+ "provider.model-budget-limit",
1158
+ "provider.transport",
1159
+ "provider.timeout",
1160
+ "provider.internal",
1161
+ "provider.invalid-output"
1162
+ ]);
1163
+ function isRateLimitError(error) {
1164
+ if (!error || typeof error !== "object") return false;
1165
+ const value = error;
1166
+ return value.type !== void 0 && RETRYABLE.has(value.type);
1167
+ }
1168
+ function modelRef(value) {
1169
+ const slash = value.indexOf("/");
1170
+ if (slash <= 0 || slash === value.length - 1) return;
1171
+ const [id, variant] = value.slice(slash + 1).split("#");
1172
+ if (!id) return;
1173
+ return {
1174
+ providerID: value.slice(0, slash),
1175
+ id,
1176
+ ...variant ? { variant } : {}
1177
+ };
1178
+ }
1179
+ var ForegroundFallbackManager = class {
1180
+ constructor(session, chains, models = {}) {
1181
+ this.session = session;
1182
+ this.chains = chains;
1183
+ this.models = models;
841
1184
  }
842
- const healthUrl = new URL("/health", serverUrl).toString();
843
- const timeoutMs = 3e3;
844
- const maxAttempts = 2;
845
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
846
- const controller = new AbortController();
847
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
848
- let response = null;
1185
+ session;
1186
+ chains;
1187
+ models;
1188
+ tried = /* @__PURE__ */ new Map();
1189
+ switching = /* @__PURE__ */ new Set();
1190
+ forget(sessionID) {
1191
+ this.tried.delete(sessionID);
1192
+ }
1193
+ async retry(input) {
1194
+ if (!Object.hasOwn(this.chains, input.agent)) return;
1195
+ const chain = this.chains[input.agent];
1196
+ const current = `${input.model.providerID}/${input.model.id}`;
1197
+ const inChain = chain.some((entry) => {
1198
+ const ref = modelRef(entry);
1199
+ return ref && `${ref.providerID}/${ref.id}` === current;
1200
+ });
1201
+ if (!inChain || !isRateLimitError(input.error)) return;
1202
+ const lock = JSON.stringify([input.sessionID, input.agent]);
1203
+ if (this.switching.has(lock)) return;
1204
+ this.switching.add(lock);
849
1205
  try {
850
- response = await fetch(healthUrl, { signal: controller.signal }).catch(
851
- () => null
852
- );
1206
+ let agents = this.tried.get(input.sessionID);
1207
+ if (!agents) {
1208
+ agents = /* @__PURE__ */ new Map();
1209
+ this.tried.set(input.sessionID, agents);
1210
+ }
1211
+ let tried = agents.get(input.agent);
1212
+ if (!tried) {
1213
+ tried = /* @__PURE__ */ new Set();
1214
+ agents.set(input.agent, tried);
1215
+ }
1216
+ tried.add(current);
1217
+ for (const entry of chain) {
1218
+ const ref = modelRef(entry);
1219
+ if (!ref) continue;
1220
+ const id = `${ref.providerID}/${ref.id}`;
1221
+ if (tried.has(id)) continue;
1222
+ tried.add(id);
1223
+ const variant = this.models[input.agent]?.find(
1224
+ (model) => model.id === entry
1225
+ )?.variant;
1226
+ await this.session.switchModel({
1227
+ sessionID: input.sessionID,
1228
+ model: {
1229
+ ...ref,
1230
+ ...variant ? { variant } : {}
1231
+ }
1232
+ });
1233
+ input.decision = { retry: true, delay: 0 };
1234
+ return;
1235
+ }
1236
+ } catch (error) {
1237
+ log("[foreground-fallback] model switch failed", {
1238
+ sessionID: input.sessionID,
1239
+ error: String(error)
1240
+ });
853
1241
  } finally {
854
- clearTimeout(timeout);
855
- }
856
- const available = response?.ok ?? false;
857
- if (available) {
858
- serverCheckUrl = serverUrl;
859
- serverAvailable = true;
860
- log("[tmux] isServerRunning: checked", { serverUrl, available, attempt });
861
- return true;
862
- }
863
- if (attempt < maxAttempts) {
864
- await new Promise((r) => setTimeout(r, 250));
1242
+ this.switching.delete(lock);
865
1243
  }
866
1244
  }
867
- log("[tmux] isServerRunning: checked", { serverUrl, available: false });
868
- return false;
869
- }
870
- async function findTmuxPath() {
871
- const isWindows = process.platform === "win32";
872
- const cmd = isWindows ? "where" : "which";
873
- try {
874
- const proc = spawn([cmd, "tmux"], {
875
- stdout: "pipe",
876
- stderr: "pipe"
1245
+ };
1246
+
1247
+ // src/opencode/session-state.ts
1248
+ import { readdir, readFile, stat } from "fs/promises";
1249
+ import { join as join2 } from "path";
1250
+ function missing(error) {
1251
+ return error.code === "ENOENT";
1252
+ }
1253
+ var SessionState = class {
1254
+ constructor(directory, instructions) {
1255
+ this.directory = directory;
1256
+ this.instructions = instructions;
1257
+ }
1258
+ directory;
1259
+ instructions;
1260
+ parents = /* @__PURE__ */ new Map();
1261
+ running = /* @__PURE__ */ new Set();
1262
+ published = /* @__PURE__ */ new Map();
1263
+ records = /* @__PURE__ */ new Map();
1264
+ queue = Promise.resolve();
1265
+ serialize(run) {
1266
+ const result = this.queue.then(run);
1267
+ this.queue = result.catch(() => {
877
1268
  });
878
- const exitCode = await proc.exited;
879
- if (exitCode !== 0) {
880
- log("[tmux] findTmuxPath: 'which tmux' failed", { exitCode });
881
- return null;
882
- }
883
- const stdout = await new Response(proc.stdout).text();
884
- const path3 = stdout.trim().split("\n")[0];
885
- if (!path3) {
886
- log("[tmux] findTmuxPath: no path in output");
887
- return null;
888
- }
889
- const verifyProc = spawn([path3, "-V"], {
890
- stdout: "pipe",
891
- stderr: "pipe"
1269
+ return result;
1270
+ }
1271
+ refresh(sessionID) {
1272
+ return this.serialize(() => this.publish(sessionID));
1273
+ }
1274
+ event(event) {
1275
+ return this.serialize(async () => {
1276
+ const { sessionID } = event.data;
1277
+ if (event.type === "session.created") {
1278
+ if (event.data.parentID) {
1279
+ this.parents.set(sessionID, event.data.parentID);
1280
+ this.running.add(sessionID);
1281
+ await this.publish(event.data.parentID);
1282
+ }
1283
+ await this.publish(sessionID);
1284
+ return;
1285
+ }
1286
+ const parent = this.parents.get(sessionID);
1287
+ if (event.type === "session.status") {
1288
+ if (event.data.status.type === "idle") this.running.delete(sessionID);
1289
+ else if (parent) this.running.add(sessionID);
1290
+ if (parent) await this.publish(parent);
1291
+ if (this.published.has(sessionID)) await this.publish(sessionID);
1292
+ return;
1293
+ }
1294
+ this.parents.delete(sessionID);
1295
+ this.running.delete(sessionID);
1296
+ this.published.delete(sessionID);
1297
+ for (const [child, owner] of this.parents) {
1298
+ if (owner !== sessionID) continue;
1299
+ this.parents.delete(child);
1300
+ this.running.delete(child);
1301
+ }
1302
+ if (parent) await this.publish(parent);
892
1303
  });
893
- const verifyExit = await verifyProc.exited;
894
- if (verifyExit !== 0) {
895
- log("[tmux] findTmuxPath: tmux -V failed", { path: path3, verifyExit });
896
- return null;
897
- }
898
- log("[tmux] findTmuxPath: found tmux", { path: path3 });
899
- return path3;
900
- } catch (err) {
901
- log("[tmux] findTmuxPath: exception", { error: String(err) });
902
- return null;
903
1304
  }
904
- }
905
- async function getTmuxPath() {
906
- if (tmuxChecked) {
907
- return tmuxPath;
1305
+ async inventory() {
1306
+ const root = join2(this.directory, ".thoth", "changes");
1307
+ const entries = await readdir(root, { withFileTypes: true }).catch(
1308
+ (error) => {
1309
+ if (missing(error)) return [];
1310
+ throw error;
1311
+ }
1312
+ );
1313
+ const active = /* @__PURE__ */ new Set();
1314
+ for (const entry of entries) {
1315
+ if (!entry.isDirectory() || entry.name === "archive") continue;
1316
+ const id = entry.name;
1317
+ const path3 = join2(root, id, `${id}.md`);
1318
+ try {
1319
+ const info = await stat(path3);
1320
+ if (!info.isFile()) continue;
1321
+ active.add(id);
1322
+ const fingerprint = `${info.mtimeMs}:${info.ctimeMs}:${info.size}`;
1323
+ if (this.records.get(id)?.fingerprint === fingerprint) continue;
1324
+ const content = await readFile(path3, "utf8");
1325
+ const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim() ?? id;
1326
+ const status = content.match(/^\*\*(?:Status|Phase)\*\*:\s*.+$/gm) ?? [];
1327
+ this.records.set(id, {
1328
+ fingerprint,
1329
+ label: `${title}${status.length ? ` (${status.join("; ")})` : ""}`
1330
+ });
1331
+ } catch (error) {
1332
+ if (!missing(error)) throw error;
1333
+ active.delete(id);
1334
+ }
1335
+ }
1336
+ for (const id of this.records.keys())
1337
+ if (!active.has(id)) this.records.delete(id);
1338
+ return [...this.records].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(
1339
+ ([id, record]) => `- .thoth/changes/${id}/${id}.md \u2014 ${record.label}`
1340
+ );
908
1341
  }
909
- tmuxPath = await findTmuxPath();
910
- tmuxChecked = true;
911
- log("[tmux] getTmuxPath: initialized", { tmuxPath });
912
- return tmuxPath;
913
- }
914
- function isInsideTmux() {
915
- return !!process.env.TMUX;
916
- }
917
- async function applyLayout(tmux, layout, mainPaneSize) {
918
- try {
919
- const layoutProc = spawn([tmux, "select-layout", layout], {
920
- stdout: "pipe",
921
- stderr: "pipe"
922
- });
923
- await layoutProc.exited;
924
- if (layout === "main-horizontal" || layout === "main-vertical") {
925
- const sizeOption = layout === "main-horizontal" ? "main-pane-height" : "main-pane-width";
926
- const sizeProc = spawn(
927
- [tmux, "set-window-option", sizeOption, `${mainPaneSize}%`],
928
- {
929
- stdout: "pipe",
930
- stderr: "pipe"
931
- }
932
- );
933
- await sizeProc.exited;
934
- const reapplyProc = spawn([tmux, "select-layout", layout], {
935
- stdout: "pipe",
936
- stderr: "pipe"
1342
+ async publish(sessionID) {
1343
+ const records = await this.inventory();
1344
+ const children = [...this.parents].filter(
1345
+ ([child, parent]) => parent === sessionID && this.running.has(child)
1346
+ ).map(([child]) => child).sort();
1347
+ const value = [
1348
+ "Thoth state",
1349
+ "Active SDD records:",
1350
+ ...records.length ? records : ["- none"],
1351
+ "Running delegated sessions:",
1352
+ ...children.length ? children.map((id) => `- ${id}`) : ["- none"]
1353
+ ].join("\n");
1354
+ if (this.published.get(sessionID) !== value) {
1355
+ await this.instructions.entry.put({
1356
+ sessionID,
1357
+ key: "thoth.state",
1358
+ value
937
1359
  });
938
- await reapplyProc.exited;
1360
+ this.published.set(sessionID, value);
939
1361
  }
940
- log("[tmux] applyLayout: applied", { layout, mainPaneSize });
941
- } catch (err) {
942
- log("[tmux] applyLayout: exception", { error: String(err) });
1362
+ return value;
943
1363
  }
944
- }
945
- async function spawnTmuxPane(sessionId, description, config, serverUrl) {
946
- log("[tmux] spawnTmuxPane called", {
947
- sessionId,
948
- description,
949
- config,
950
- serverUrl
1364
+ };
1365
+
1366
+ // src/opencode/session-hooks.ts
1367
+ async function registerSessionHooks(ctx, deps) {
1368
+ const names = new Set(deps.agentDefs.map((agent) => agent.name));
1369
+ const fallback = new ForegroundFallbackManager(
1370
+ ctx.session,
1371
+ deps.runtimeChains,
1372
+ deps.effectiveModelArrays
1373
+ );
1374
+ const state = new SessionState(deps.directory, ctx.session.instructions);
1375
+ await ctx.session.hook("retry", (input) => {
1376
+ if (names.has(input.agent)) return fallback.retry(input);
951
1377
  });
952
- if (!config.enabled) {
953
- log("[tmux] spawnTmuxPane: config.enabled is false, skipping");
954
- return { success: false };
955
- }
956
- if (!isInsideTmux()) {
957
- log("[tmux] spawnTmuxPane: not inside tmux, skipping");
958
- return { success: false };
959
- }
960
- const serverRunning = await isServerRunning(serverUrl);
961
- if (!serverRunning) {
962
- const defaultPort = process.env.OPENCODE_PORT ?? "4096";
963
- log("[tmux] spawnTmuxPane: OpenCode server not running, skipping", {
964
- serverUrl,
965
- hint: `Start opencode with --port ${defaultPort}`
966
- });
967
- return { success: false };
968
- }
969
- const tmux = await getTmuxPath();
970
- if (!tmux) {
971
- log("[tmux] spawnTmuxPane: tmux binary not found, skipping");
972
- return { success: false };
973
- }
974
- storedConfig = config;
975
- try {
976
- const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
977
- const args = [
978
- "split-window",
979
- "-h",
980
- "-d",
981
- // Don't switch focus to new pane
982
- "-P",
983
- // Print pane info
984
- "-F",
985
- "#{pane_id}",
986
- // Format: just the pane ID
987
- opencodeCmd
988
- ];
989
- log("[tmux] spawnTmuxPane: executing", { tmux, args, opencodeCmd });
990
- const proc = spawn([tmux, ...args], {
991
- stdout: "pipe",
992
- stderr: "pipe"
993
- });
994
- const exitCode = await proc.exited;
995
- const stdout = await new Response(proc.stdout).text();
996
- const stderr = await new Response(proc.stderr).text();
997
- const paneId = stdout.trim();
998
- log("[tmux] spawnTmuxPane: split result", {
999
- exitCode,
1000
- paneId,
1001
- stderr: stderr.trim()
1378
+ await ctx.session.hook("prompt", async (input) => {
1379
+ await state.refresh(input.sessionID);
1380
+ });
1381
+ await ctx.session.hook("compaction", async (input) => {
1382
+ const value = await state.refresh(input.sessionID);
1383
+ if (input.result) {
1384
+ input.result.summary += `
1385
+
1386
+ ${value}`;
1387
+ return;
1388
+ }
1389
+ input.messages.push({
1390
+ role: "user",
1391
+ content: [
1392
+ {
1393
+ type: "text",
1394
+ text: `Preserve this current Thoth state in the compaction summary:
1395
+ ${value}`
1396
+ }
1397
+ ]
1002
1398
  });
1003
- if (exitCode === 0 && paneId) {
1004
- const renameProc = spawn(
1005
- [tmux, "select-pane", "-t", paneId, "-T", description.slice(0, 30)],
1006
- { stdout: "ignore", stderr: "ignore" }
1399
+ });
1400
+ const signal = new AbortController();
1401
+ const events = ctx.event.subscribe({
1402
+ types: ["session.created", "session.status", "session.deleted"],
1403
+ signal: signal.signal
1404
+ });
1405
+ void (async () => {
1406
+ try {
1407
+ for await (const event of events) {
1408
+ if (event.type === "session.deleted")
1409
+ fallback.forget(event.data.sessionID);
1410
+ try {
1411
+ await state.event(event);
1412
+ } catch (error) {
1413
+ log("[session-state] update failed", { error: String(error) });
1414
+ }
1415
+ }
1416
+ } catch (error) {
1417
+ log("[session-state] subscription ended", { error: String(error) });
1418
+ } finally {
1419
+ signal.abort();
1420
+ }
1421
+ })();
1422
+ }
1423
+
1424
+ // src/opencode/skills.ts
1425
+ import { readFile as readFile2 } from "fs/promises";
1426
+ import { resolve } from "path";
1427
+ import { parse } from "yaml";
1428
+ var ownedSkills = [
1429
+ "thoth-init",
1430
+ "thoth-sdd",
1431
+ "thoth-constitution",
1432
+ "thoth-archive",
1433
+ "plan-reviewer"
1434
+ ];
1435
+ async function registerSkills(ctx, deps) {
1436
+ const skills = [];
1437
+ for (const id of ownedSkills) {
1438
+ const path3 = resolve(deps.packageRoot, "skills", id, "SKILL.md");
1439
+ try {
1440
+ const source = await readFile2(path3, "utf8");
1441
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(
1442
+ source
1007
1443
  );
1008
- await renameProc.exited;
1009
- const layout = config.layout ?? "main-vertical";
1010
- const mainPaneSize = config.main_pane_size ?? 60;
1011
- await applyLayout(tmux, layout, mainPaneSize);
1012
- log("[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied", {
1013
- paneId,
1014
- layout
1444
+ if (!match) throw new Error("Missing YAML frontmatter");
1445
+ const metadata = parse(match[1]);
1446
+ if (metadata?.name !== id || typeof metadata?.description !== "string" || !metadata.description.trim()) {
1447
+ throw new Error(`Expected name ${id} and a non-empty description`);
1448
+ }
1449
+ skills.push({
1450
+ id,
1451
+ name: metadata.name,
1452
+ description: metadata.description,
1453
+ path: path3,
1454
+ content: match[2].trim()
1015
1455
  });
1016
- return { success: true, paneId };
1456
+ } catch (cause) {
1457
+ throw new Error(
1458
+ `Cannot load bundled skill ${path3}: ${cause instanceof Error ? cause.message : String(cause)}`,
1459
+ { cause }
1460
+ );
1017
1461
  }
1018
- return { success: false };
1019
- } catch (err) {
1020
- log("[tmux] spawnTmuxPane: exception", { error: String(err) });
1021
- return { success: false };
1022
1462
  }
1463
+ await ctx.skill.transform((editor) => {
1464
+ for (const skill of skills) {
1465
+ if (editor.get(skill.id))
1466
+ editor.update(skill.id, (existing) => {
1467
+ Object.assign(existing, skill);
1468
+ });
1469
+ else editor.add(skill);
1470
+ }
1471
+ });
1023
1472
  }
1024
- async function closeTmuxPane(paneId) {
1025
- log("[tmux] closeTmuxPane called", { paneId });
1026
- if (!paneId) {
1027
- log("[tmux] closeTmuxPane: no paneId provided");
1028
- return false;
1473
+
1474
+ // src/opencode/tool-rpc-handlers.ts
1475
+ async function registerToolRpc(ctx, deps) {
1476
+ const { store } = deps.toolState;
1477
+ async function state() {
1478
+ const snapshot = store.resolve();
1479
+ const grouped = /* @__PURE__ */ new Map();
1480
+ for (const tool of await ctx.tool.list()) {
1481
+ const action = tool.options?.permission ?? tool.id;
1482
+ const members = grouped.get(action) ?? [];
1483
+ members.push(tool.id);
1484
+ grouped.set(action, members);
1485
+ }
1486
+ if (!grouped.has("subagent")) grouped.set("subagent", []);
1487
+ const inventory = [...grouped].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([action, tools]) => ({
1488
+ action,
1489
+ tools: tools.sort(),
1490
+ locked: action === "subagent"
1491
+ }));
1492
+ const actions = inventory.map(({ action }) => action);
1493
+ return {
1494
+ specialists: [...SPECIALIST_ROLES],
1495
+ inventory,
1496
+ roles: SPECIALIST_ROLES.map((role) => {
1497
+ const scopeState = (scope) => ({
1498
+ present: Object.hasOwn(snapshot[scope].document.roles, role),
1499
+ revision: snapshot[scope].revision,
1500
+ overrides: (snapshot[scope].document.roles[role]?.overrides ?? []).map((entry) => ({
1501
+ ...entry,
1502
+ matchCount: countActionMatches(entry.action, actions)
1503
+ }))
1504
+ });
1505
+ const base = translatePermissionRules(deps.agents[role].permission);
1506
+ return {
1507
+ role,
1508
+ effectiveScope: snapshot.effective[role]?.origin ?? "default",
1509
+ global: scopeState("global"),
1510
+ project: scopeState("project"),
1511
+ actions: inventory.map(({ action, locked }) => ({
1512
+ action,
1513
+ locked,
1514
+ baseEffect: evaluateAction(base, void 0, action).effect,
1515
+ ...evaluateAction(base, snapshot.effective[role], action)
1516
+ }))
1517
+ };
1518
+ }),
1519
+ warnings: snapshot.warnings
1520
+ };
1029
1521
  }
1030
- const tmux = await getTmuxPath();
1031
- if (!tmux) {
1032
- log("[tmux] closeTmuxPane: tmux binary not found");
1033
- return false;
1522
+ let pending = Promise.resolve();
1523
+ function handler(method, run) {
1524
+ return (raw) => {
1525
+ const operation = pending.then(async () => {
1526
+ const parsed = toolRpc.methods[method].input.safeParse(raw);
1527
+ if (!parsed.success)
1528
+ return {
1529
+ ok: false,
1530
+ error: { code: "INVALID_REQUEST", message: parsed.error.message }
1531
+ };
1532
+ try {
1533
+ return { ok: true, value: await run(parsed.data) };
1534
+ } catch (error) {
1535
+ return {
1536
+ ok: false,
1537
+ error: {
1538
+ code: error instanceof ToolsConflictError ? "TOOLS_CONFLICT" : "OPERATION_FAILED",
1539
+ message: error instanceof Error ? error.message : String(error)
1540
+ }
1541
+ };
1542
+ }
1543
+ });
1544
+ pending = operation;
1545
+ return operation;
1546
+ };
1034
1547
  }
1035
- try {
1036
- log("[tmux] closeTmuxPane: sending Ctrl+C for graceful shutdown", {
1037
- paneId
1038
- });
1039
- const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
1040
- stdout: "pipe",
1041
- stderr: "pipe"
1042
- });
1043
- await ctrlCProc.exited;
1044
- await new Promise((r) => setTimeout(r, 250));
1045
- log("[tmux] closeTmuxPane: killing pane", { paneId });
1046
- const proc = spawn([tmux, "kill-pane", "-t", paneId], {
1047
- stdout: "pipe",
1048
- stderr: "pipe"
1049
- });
1050
- const exitCode = await proc.exited;
1051
- const stderr = await new Response(proc.stderr).text();
1052
- log("[tmux] closeTmuxPane: result", { exitCode, stderr: stderr.trim() });
1053
- if (exitCode === 0) {
1054
- log("[tmux] closeTmuxPane: SUCCESS, pane closed", { paneId });
1055
- if (storedConfig) {
1056
- const layout = storedConfig.layout ?? "main-vertical";
1057
- const mainPaneSize = storedConfig.main_pane_size ?? 60;
1058
- await applyLayout(tmux, layout, mainPaneSize);
1059
- log("[tmux] closeTmuxPane: layout reapplied", { layout });
1548
+ async function reload() {
1549
+ deps.toolState.refresh();
1550
+ await ctx.agent.reload();
1551
+ return state();
1552
+ }
1553
+ const handlers = {
1554
+ state: handler("state", state),
1555
+ save: handler("save", async ({ scope, role, overrides, baseRevision }) => {
1556
+ store.save(scope, role, overrides, baseRevision);
1557
+ return reload();
1558
+ }),
1559
+ reset: handler("reset", async ({ scope, role, baseRevision }) => {
1560
+ store.reset(scope, role, baseRevision);
1561
+ return reload();
1562
+ })
1563
+ };
1564
+ await ctx.rpc.register(toolRpc, handlers);
1565
+ }
1566
+
1567
+ // src/tools/ast-grep/tools.ts
1568
+ import { z } from "zod";
1569
+
1570
+ // src/tools/define-tool.ts
1571
+ function defineTool(definition) {
1572
+ return {
1573
+ name: definition.name,
1574
+ description: definition.description,
1575
+ input: definition.input,
1576
+ async execute(input, context) {
1577
+ context.signal.throwIfAborted();
1578
+ let onAbort = () => {
1579
+ };
1580
+ const aborted = new Promise((_, reject) => {
1581
+ onAbort = () => reject(context.signal.reason);
1582
+ context.signal.addEventListener("abort", onAbort, { once: true });
1583
+ });
1584
+ try {
1585
+ const content = await Promise.race([
1586
+ definition.execute(input, context),
1587
+ aborted
1588
+ ]);
1589
+ context.signal.throwIfAborted();
1590
+ if (definition.showOutput) {
1591
+ await context.progress({ output: content });
1592
+ context.signal.throwIfAborted();
1593
+ return { content, metadata: { output: content } };
1594
+ }
1595
+ return { content };
1596
+ } finally {
1597
+ context.signal.removeEventListener("abort", onAbort);
1060
1598
  }
1061
- return true;
1062
1599
  }
1063
- log("[tmux] closeTmuxPane: failed (pane may already be closed)", {
1064
- paneId
1065
- });
1066
- return false;
1067
- } catch (err) {
1068
- log("[tmux] closeTmuxPane: exception", { error: String(err) });
1069
- return false;
1070
- }
1071
- }
1072
- function startTmuxCheck() {
1073
- if (!tmuxChecked) {
1074
- getTmuxPath().catch(() => {
1075
- });
1076
- }
1600
+ };
1077
1601
  }
1078
1602
 
1603
+ // src/tools/ast-grep/cli.ts
1604
+ import { existsSync as existsSync4 } from "fs";
1605
+
1606
+ // src/tools/ast-grep/constants.ts
1607
+ import { spawnSync as spawnSync2 } from "child_process";
1608
+ import { existsSync as existsSync3, statSync } from "fs";
1609
+ import { createRequire as createRequire3 } from "module";
1610
+ import { dirname, join as join4 } from "path";
1611
+
1612
+ // src/tools/ast-grep/downloader.ts
1613
+ import { chmodSync, existsSync as existsSync2, mkdirSync, unlinkSync } from "fs";
1614
+ import { createRequire as createRequire2 } from "module";
1615
+ import { homedir } from "os";
1616
+ import { join as join3 } from "path";
1617
+
1079
1618
  // src/utils/zip-extractor.ts
1080
1619
  import { release } from "os";
1081
1620
  var WINDOWS_BUILD_WITH_TAR = 17134;
@@ -1166,7 +1705,7 @@ async function extractZip(archivePath, destDir) {
1166
1705
  }
1167
1706
 
1168
1707
  // src/utils/file-io.ts
1169
- import { readFile, writeFile } from "fs/promises";
1708
+ import { readFile as readFile3, writeFile } from "fs/promises";
1170
1709
  async function writeTextFile(filePath, contents) {
1171
1710
  await writeFile(
1172
1711
  filePath,
@@ -1195,22 +1734,22 @@ var PLATFORM_MAP = {
1195
1734
  "win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
1196
1735
  "win32-ia32": { arch: "i686", os: "pc-windows-msvc" }
1197
1736
  };
1198
- function getCacheDir2() {
1737
+ function getCacheDir() {
1199
1738
  if (process.platform === "win32") {
1200
1739
  const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA;
1201
- const base2 = localAppData || join4(homedir2(), "AppData", "Local");
1202
- return join4(base2, "thoth-agents", "bin");
1740
+ const base2 = localAppData || join3(homedir(), "AppData", "Local");
1741
+ return join3(base2, "thoth-agents", "bin");
1203
1742
  }
1204
1743
  const xdgCache = process.env.XDG_CACHE_HOME;
1205
- const base = xdgCache || join4(homedir2(), ".cache");
1206
- return join4(base, "thoth-agents", "bin");
1744
+ const base = xdgCache || join3(homedir(), ".cache");
1745
+ return join3(base, "thoth-agents", "bin");
1207
1746
  }
1208
1747
  function getBinaryName() {
1209
1748
  return process.platform === "win32" ? "sg.exe" : "sg";
1210
1749
  }
1211
1750
  function getCachedBinaryPath() {
1212
- const binaryPath = join4(getCacheDir2(), getBinaryName());
1213
- return existsSync3(binaryPath) ? binaryPath : null;
1751
+ const binaryPath = join3(getCacheDir(), getBinaryName());
1752
+ return existsSync2(binaryPath) ? binaryPath : null;
1214
1753
  }
1215
1754
  async function downloadAstGrep(version = DEFAULT_VERSION) {
1216
1755
  const platformKey = `${process.platform}-${process.arch}`;
@@ -1221,10 +1760,10 @@ async function downloadAstGrep(version = DEFAULT_VERSION) {
1221
1760
  );
1222
1761
  return null;
1223
1762
  }
1224
- const cacheDir = getCacheDir2();
1763
+ const cacheDir = getCacheDir();
1225
1764
  const binaryName = getBinaryName();
1226
- const binaryPath = join4(cacheDir, binaryName);
1227
- if (existsSync3(binaryPath)) {
1765
+ const binaryPath = join3(cacheDir, binaryName);
1766
+ if (existsSync2(binaryPath)) {
1228
1767
  return binaryPath;
1229
1768
  }
1230
1769
  const { arch, os: os2 } = platformInfo;
@@ -1232,21 +1771,21 @@ async function downloadAstGrep(version = DEFAULT_VERSION) {
1232
1771
  const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`;
1233
1772
  console.log(`[thoth-agents] Downloading ast-grep binary...`);
1234
1773
  try {
1235
- if (!existsSync3(cacheDir)) {
1774
+ if (!existsSync2(cacheDir)) {
1236
1775
  mkdirSync(cacheDir, { recursive: true });
1237
1776
  }
1238
1777
  const response = await fetch(downloadUrl, { redirect: "follow" });
1239
1778
  if (!response.ok) {
1240
1779
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1241
1780
  }
1242
- const archivePath = join4(cacheDir, assetName);
1781
+ const archivePath = join3(cacheDir, assetName);
1243
1782
  const arrayBuffer = await response.arrayBuffer();
1244
1783
  await writeTextFile(archivePath, arrayBuffer);
1245
1784
  await extractZip(archivePath, cacheDir);
1246
- if (existsSync3(archivePath)) {
1785
+ if (existsSync2(archivePath)) {
1247
1786
  unlinkSync(archivePath);
1248
1787
  }
1249
- if (process.platform !== "win32" && existsSync3(binaryPath)) {
1788
+ if (process.platform !== "win32" && existsSync2(binaryPath)) {
1250
1789
  chmodSync(binaryPath, 493);
1251
1790
  }
1252
1791
  console.log(`[thoth-agents] ast-grep binary ready.`);
@@ -1300,7 +1839,7 @@ var CLI_LANGUAGES = [
1300
1839
  var MIN_BINARY_SIZE = 1e4;
1301
1840
  function isValidBinary(filePath) {
1302
1841
  try {
1303
- return statSync2(filePath).size > MIN_BINARY_SIZE;
1842
+ return statSync(filePath).size > MIN_BINARY_SIZE;
1304
1843
  } catch {
1305
1844
  return false;
1306
1845
  }
@@ -1329,9 +1868,9 @@ function findSgCliPathSync() {
1329
1868
  try {
1330
1869
  const require3 = createRequire3(import.meta.url);
1331
1870
  const cliPkgPath = require3.resolve("@ast-grep/cli/package.json");
1332
- const cliDir = dirname2(cliPkgPath);
1333
- const sgPath = join5(cliDir, binaryName);
1334
- if (existsSync4(sgPath) && isValidBinary(sgPath)) {
1871
+ const cliDir = dirname(cliPkgPath);
1872
+ const sgPath = join4(cliDir, binaryName);
1873
+ if (existsSync3(sgPath) && isValidBinary(sgPath)) {
1335
1874
  return sgPath;
1336
1875
  }
1337
1876
  } catch {
@@ -1341,10 +1880,10 @@ function findSgCliPathSync() {
1341
1880
  try {
1342
1881
  const require3 = createRequire3(import.meta.url);
1343
1882
  const pkgPath = require3.resolve(`${platformPkg}/package.json`);
1344
- const pkgDir = dirname2(pkgPath);
1883
+ const pkgDir = dirname(pkgPath);
1345
1884
  const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep";
1346
- const binaryPath = join5(pkgDir, astGrepName);
1347
- if (existsSync4(binaryPath) && isValidBinary(binaryPath)) {
1885
+ const binaryPath = join4(pkgDir, astGrepName);
1886
+ if (existsSync3(binaryPath) && isValidBinary(binaryPath)) {
1348
1887
  return binaryPath;
1349
1888
  }
1350
1889
  } catch {
@@ -1353,7 +1892,7 @@ function findSgCliPathSync() {
1353
1892
  if (process.platform === "darwin") {
1354
1893
  const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"];
1355
1894
  for (const path3 of homebrewPaths) {
1356
- if (existsSync4(path3) && isValidBinary(path3)) {
1895
+ if (existsSync3(path3) && isValidBinary(path3)) {
1357
1896
  return path3;
1358
1897
  }
1359
1898
  }
@@ -1382,7 +1921,7 @@ var DEFAULT_MAX_MATCHES = 500;
1382
1921
  var initPromise = null;
1383
1922
  async function getAstGrepPath() {
1384
1923
  const currentPath = getSgCliPath();
1385
- if (currentPath !== "sg" && existsSync5(currentPath)) {
1924
+ if (currentPath !== "sg" && existsSync4(currentPath)) {
1386
1925
  return currentPath;
1387
1926
  }
1388
1927
  if (initPromise) {
@@ -1390,7 +1929,7 @@ async function getAstGrepPath() {
1390
1929
  }
1391
1930
  initPromise = (async () => {
1392
1931
  const syncPath = findSgCliPathSync();
1393
- if (syncPath && existsSync5(syncPath)) {
1932
+ if (syncPath && existsSync4(syncPath)) {
1394
1933
  setSgCliPath(syncPath);
1395
1934
  return syncPath;
1396
1935
  }
@@ -1404,6 +1943,7 @@ async function getAstGrepPath() {
1404
1943
  return initPromise;
1405
1944
  }
1406
1945
  async function runSg(options) {
1946
+ options.signal?.throwIfAborted();
1407
1947
  const args = [
1408
1948
  "run",
1409
1949
  "-p",
@@ -1429,18 +1969,21 @@ async function runSg(options) {
1429
1969
  const paths = options.paths && options.paths.length > 0 ? options.paths : ["."];
1430
1970
  args.push(...paths);
1431
1971
  let cliPath = getSgCliPath();
1432
- if (!existsSync5(cliPath) && cliPath !== "sg") {
1972
+ if (!existsSync4(cliPath) && cliPath !== "sg") {
1433
1973
  const downloadedPath = await getAstGrepPath();
1434
1974
  if (downloadedPath) {
1435
1975
  cliPath = downloadedPath;
1436
1976
  }
1437
1977
  }
1978
+ options.signal?.throwIfAborted();
1438
1979
  const timeout = DEFAULT_TIMEOUT_MS;
1439
1980
  const proc = spawn([cliPath, ...args], {
1440
1981
  windowsHide: true,
1441
1982
  stdout: "pipe",
1442
1983
  stderr: "pipe"
1443
1984
  });
1985
+ const onAbort = () => proc.kill();
1986
+ options.signal?.addEventListener("abort", onAbort, { once: true });
1444
1987
  const timeoutPromise = new Promise((_, reject) => {
1445
1988
  const id = setTimeout(() => {
1446
1989
  proc.kill();
@@ -1459,6 +2002,7 @@ async function runSg(options) {
1459
2002
  stderr = await new Response(proc.stderr).text();
1460
2003
  exitCode = await proc.exited;
1461
2004
  } catch (e) {
2005
+ options.signal?.throwIfAborted();
1462
2006
  const error = e;
1463
2007
  if (error.message?.includes("timeout")) {
1464
2008
  return {
@@ -1495,7 +2039,10 @@ Auto-download failed. Manual install options:
1495
2039
  truncated: false,
1496
2040
  error: `Failed to spawn ast-grep: ${error.message}`
1497
2041
  };
2042
+ } finally {
2043
+ options.signal?.removeEventListener("abort", onAbort);
1498
2044
  }
2045
+ options.signal?.throwIfAborted();
1499
2046
  if (exitCode !== 0 && stdout.trim() === "") {
1500
2047
  if (stderr.includes("No files found")) {
1501
2048
  return { matches: [], totalMatches: 0, truncated: false };
@@ -1649,24 +2196,23 @@ function getEmptyResultHint(pattern, lang) {
1649
2196
  }
1650
2197
 
1651
2198
  // src/tools/ast-grep/tools.ts
1652
- function showOutputToUser(context, output) {
1653
- const ctx = context;
1654
- ctx.metadata?.({ metadata: { output } });
1655
- }
1656
- var ast_grep_search = tool({
2199
+ var ast_grep_search = defineTool({
2200
+ showOutput: true,
2201
+ name: "ast_grep_search",
1657
2202
  description: "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. Use meta-variables: $VAR (single node), $$$ (multiple nodes). IMPORTANT: Patterns must be complete AST nodes (valid code). For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
1658
- args: {
1659
- pattern: tool.schema.string().describe(
2203
+ input: z.object({
2204
+ pattern: z.string().describe(
1660
2205
  "AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."
1661
2206
  ),
1662
- lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
1663
- paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
1664
- globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
1665
- context: tool.schema.number().optional().describe("Context lines around match")
1666
- },
2207
+ lang: z.enum(CLI_LANGUAGES).describe("Target language"),
2208
+ paths: z.array(z.string()).optional().describe("Paths to search (default: ['.'])"),
2209
+ globs: z.array(z.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
2210
+ context: z.number().optional().describe("Context lines around match")
2211
+ }),
1667
2212
  execute: async (args, context) => {
1668
2213
  try {
1669
2214
  const result = await runSg({
2215
+ signal: context.signal,
1670
2216
  pattern: args.pattern,
1671
2217
  lang: args.lang,
1672
2218
  paths: args.paths,
@@ -1682,28 +2228,28 @@ var ast_grep_search = tool({
1682
2228
  ${hint}`;
1683
2229
  }
1684
2230
  }
1685
- showOutputToUser(context, output);
1686
2231
  return output;
1687
2232
  } catch (e) {
1688
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`;
1689
- showOutputToUser(context, output);
1690
- return output;
2233
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
1691
2234
  }
1692
2235
  }
1693
2236
  });
1694
- var ast_grep_replace = tool({
2237
+ var ast_grep_replace = defineTool({
2238
+ showOutput: true,
2239
+ name: "ast_grep_replace",
1695
2240
  description: "Replace code patterns across filesystem with AST-aware rewriting. Dry-run by default. Use meta-variables in rewrite to preserve matched content. Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
1696
- args: {
1697
- pattern: tool.schema.string().describe("AST pattern to match"),
1698
- rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
1699
- lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
1700
- paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
1701
- globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
1702
- dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)")
1703
- },
2241
+ input: z.object({
2242
+ pattern: z.string().describe("AST pattern to match"),
2243
+ rewrite: z.string().describe("Replacement pattern (can use $VAR from pattern)"),
2244
+ lang: z.enum(CLI_LANGUAGES).describe("Target language"),
2245
+ paths: z.array(z.string()).optional().describe("Paths to search"),
2246
+ globs: z.array(z.string()).optional().describe("Include/exclude globs"),
2247
+ dryRun: z.boolean().optional().describe("Preview changes without applying (default: true)")
2248
+ }),
1704
2249
  execute: async (args, context) => {
1705
2250
  try {
1706
2251
  const result = await runSg({
2252
+ signal: context.signal,
1707
2253
  pattern: args.pattern,
1708
2254
  rewrite: args.rewrite,
1709
2255
  lang: args.lang,
@@ -1711,20 +2257,16 @@ var ast_grep_replace = tool({
1711
2257
  globs: args.globs,
1712
2258
  updateAll: args.dryRun === false
1713
2259
  });
1714
- const output = formatReplaceResult(result, args.dryRun !== false);
1715
- showOutputToUser(context, output);
1716
- return output;
2260
+ return formatReplaceResult(result, args.dryRun !== false);
1717
2261
  } catch (e) {
1718
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`;
1719
- showOutputToUser(context, output);
1720
- return output;
2262
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
1721
2263
  }
1722
2264
  }
1723
2265
  });
1724
2266
 
1725
2267
  // src/tools/lsp/client.ts
1726
- import { readFileSync as readFileSync3 } from "fs";
1727
- import { extname, resolve as resolve2 } from "path";
2268
+ import { readFileSync as readFileSync2 } from "fs";
2269
+ import { extname, resolve as resolve3 } from "path";
1728
2270
  import { Readable, Writable } from "stream";
1729
2271
  import { pathToFileURL } from "url";
1730
2272
  import {
@@ -1734,31 +2276,13 @@ import {
1734
2276
  } from "vscode-jsonrpc/node.js";
1735
2277
 
1736
2278
  // src/tools/lsp/config.ts
1737
- import { existsSync as existsSync7 } from "fs";
1738
- import { homedir as homedir3 } from "os";
1739
- import { join as join6 } from "path";
2279
+ import { existsSync as existsSync6 } from "fs";
2280
+ import { homedir as homedir2 } from "os";
2281
+ import { join as join5 } from "path";
1740
2282
  import whichSync from "which";
1741
2283
 
1742
2284
  // src/tools/lsp/config-store.ts
1743
2285
  var userConfig = /* @__PURE__ */ new Map();
1744
- function setUserLspConfig(config) {
1745
- userConfig.clear();
1746
- if (config) {
1747
- for (const [id, server] of Object.entries(config)) {
1748
- if (server && typeof server === "object") {
1749
- const s = server;
1750
- userConfig.set(id, {
1751
- id,
1752
- command: s.command,
1753
- extensions: s.extensions,
1754
- disabled: s.disabled,
1755
- env: s.env,
1756
- initialization: s.initialization
1757
- });
1758
- }
1759
- }
1760
- }
1761
- }
1762
2286
  function getAllUserLspConfigs() {
1763
2287
  return new Map(userConfig);
1764
2288
  }
@@ -1767,8 +2291,8 @@ function hasUserLspConfig() {
1767
2291
  }
1768
2292
 
1769
2293
  // src/tools/lsp/constants.ts
1770
- import { existsSync as existsSync6, readdirSync, statSync as statSync3 } from "fs";
1771
- import { dirname as dirname3, resolve } from "path";
2294
+ import { existsSync as existsSync5, readdirSync, statSync as statSync2 } from "fs";
2295
+ import { dirname as dirname2, resolve as resolve2 } from "path";
1772
2296
  var SEVERITY_MAP = {
1773
2297
  1: "error",
1774
2298
  2: "warning",
@@ -1785,20 +2309,20 @@ var LOCK_FILE_PATTERNS = [
1785
2309
  "bun.lock"
1786
2310
  ];
1787
2311
  function* walkUpDirectories(start, stop) {
1788
- let dir = resolve(start);
2312
+ let dir = resolve2(start);
1789
2313
  try {
1790
- if (!statSync3(dir).isDirectory()) {
1791
- dir = dirname3(dir);
2314
+ if (!statSync2(dir).isDirectory()) {
2315
+ dir = dirname2(dir);
1792
2316
  }
1793
2317
  } catch {
1794
- dir = dirname3(dir);
2318
+ dir = dirname2(dir);
1795
2319
  }
1796
2320
  let prevDir = "";
1797
2321
  while (dir !== prevDir && dir !== "/") {
1798
2322
  yield dir;
1799
2323
  prevDir = dir;
1800
2324
  if (dir === stop) break;
1801
- dir = dirname3(dir);
2325
+ dir = dirname2(dir);
1802
2326
  }
1803
2327
  }
1804
2328
  function NearestRoot(includePatterns, excludePatterns) {
@@ -1807,7 +2331,7 @@ function NearestRoot(includePatterns, excludePatterns) {
1807
2331
  if (excludePatterns) {
1808
2332
  for (const dir of walkUpDirectories(file, cwd)) {
1809
2333
  for (const pattern of excludePatterns) {
1810
- if (existsSync6(`${dir}/${pattern}`)) {
2334
+ if (existsSync5(`${dir}/${pattern}`)) {
1811
2335
  return void 0;
1812
2336
  }
1813
2337
  }
@@ -1826,7 +2350,7 @@ function NearestRoot(includePatterns, excludePatterns) {
1826
2350
  }
1827
2351
  } catch {
1828
2352
  }
1829
- } else if (existsSync6(`${dir}/${pattern}`)) {
2353
+ } else if (existsSync5(`${dir}/${pattern}`)) {
1830
2354
  return dir;
1831
2355
  }
1832
2356
  }
@@ -2448,11 +2972,11 @@ function isServerInstalled(command) {
2448
2972
  if (command.length === 0) return false;
2449
2973
  const cmd = command[0];
2450
2974
  if (cmd.includes("/") || cmd.includes("\\")) {
2451
- return existsSync7(cmd);
2975
+ return existsSync6(cmd);
2452
2976
  }
2453
2977
  const isWindows = process.platform === "win32";
2454
2978
  const ext = isWindows ? ".exe" : "";
2455
- const opencodeBin = join6(homedir3(), ".config", "opencode", "bin");
2979
+ const opencodeBin = join5(homedir2(), ".config", "opencode", "bin");
2456
2980
  const searchPath = (process.env.PATH ?? "") + (isWindows ? ";" : ":") + opencodeBin;
2457
2981
  const result = whichSync.sync(cmd, {
2458
2982
  path: searchPath,
@@ -2463,8 +2987,8 @@ function isServerInstalled(command) {
2463
2987
  return true;
2464
2988
  }
2465
2989
  const cwd = process.cwd();
2466
- const localBin = join6(cwd, "node_modules", ".bin", cmd);
2467
- if (existsSync7(localBin) || existsSync7(localBin + ext)) {
2990
+ const localBin = join5(cwd, "node_modules", ".bin", cmd);
2991
+ if (existsSync6(localBin) || existsSync6(localBin + ext)) {
2468
2992
  return true;
2469
2993
  }
2470
2994
  return false;
@@ -2725,7 +3249,7 @@ var LSPClient = class {
2725
3249
  this.processExited = true;
2726
3250
  });
2727
3251
  this.connection.listen();
2728
- await new Promise((resolve4) => setTimeout(resolve4, 100));
3252
+ await new Promise((resolve5) => setTimeout(resolve5, 100));
2729
3253
  if (this.proc.exitCode !== null) {
2730
3254
  const stderr = this.stderrBuffer.join("\n");
2731
3255
  log("[lsp] LSPClient.start: server exited immediately", {
@@ -2800,12 +3324,12 @@ stderr: ${stderr}` : "")
2800
3324
  log("[lsp] LSPClient.initialize: complete", { server: this.server.id });
2801
3325
  }
2802
3326
  async openFile(filePath) {
2803
- const absPath = resolve2(filePath);
3327
+ const absPath = resolve3(filePath);
2804
3328
  if (this.openedFiles.has(absPath)) {
2805
3329
  log("[lsp] openFile: already open, skipping", { filePath: absPath });
2806
3330
  return;
2807
3331
  }
2808
- const text = readFileSync3(absPath, "utf-8");
3332
+ const text = readFileSync2(absPath, "utf-8");
2809
3333
  const ext = extname(absPath);
2810
3334
  const languageId = getLanguageId(ext);
2811
3335
  log("[lsp] openFile: opening document", {
@@ -2825,7 +3349,7 @@ stderr: ${stderr}` : "")
2825
3349
  await new Promise((r) => setTimeout(r, 1e3));
2826
3350
  }
2827
3351
  async definition(filePath, line, character) {
2828
- const absPath = resolve2(filePath);
3352
+ const absPath = resolve3(filePath);
2829
3353
  await this.openFile(absPath);
2830
3354
  return this.connection?.sendRequest("textDocument/definition", {
2831
3355
  textDocument: { uri: pathToFileURL(absPath).href },
@@ -2833,7 +3357,7 @@ stderr: ${stderr}` : "")
2833
3357
  });
2834
3358
  }
2835
3359
  async references(filePath, line, character, includeDeclaration = true) {
2836
- const absPath = resolve2(filePath);
3360
+ const absPath = resolve3(filePath);
2837
3361
  await this.openFile(absPath);
2838
3362
  return this.connection?.sendRequest("textDocument/references", {
2839
3363
  textDocument: { uri: pathToFileURL(absPath).href },
@@ -2842,7 +3366,7 @@ stderr: ${stderr}` : "")
2842
3366
  });
2843
3367
  }
2844
3368
  async diagnostics(filePath) {
2845
- const absPath = resolve2(filePath);
3369
+ const absPath = resolve3(filePath);
2846
3370
  const uri = pathToFileURL(absPath).href;
2847
3371
  await this.openFile(absPath);
2848
3372
  await new Promise((r) => setTimeout(r, 500));
@@ -2861,7 +3385,7 @@ stderr: ${stderr}` : "")
2861
3385
  return { items: this.diagnosticsStore.get(uri) ?? [] };
2862
3386
  }
2863
3387
  async rename(filePath, line, character, newName) {
2864
- const absPath = resolve2(filePath);
3388
+ const absPath = resolve3(filePath);
2865
3389
  await this.openFile(absPath);
2866
3390
  return this.connection?.sendRequest("textDocument/rename", {
2867
3391
  textDocument: { uri: pathToFileURL(absPath).href },
@@ -2892,23 +3416,23 @@ stderr: ${stderr}` : "")
2892
3416
  };
2893
3417
 
2894
3418
  // src/tools/lsp/tools.ts
2895
- import { tool as tool2 } from "@opencode-ai/plugin/tool";
3419
+ import { z as z2 } from "zod";
2896
3420
 
2897
3421
  // src/tools/lsp/utils.ts
2898
3422
  import {
2899
- existsSync as existsSync8,
2900
- readFileSync as readFileSync4,
2901
- statSync as statSync4,
3423
+ existsSync as existsSync7,
3424
+ readFileSync as readFileSync3,
3425
+ statSync as statSync3,
2902
3426
  unlinkSync as unlinkSync2,
2903
3427
  writeFileSync
2904
3428
  } from "fs";
2905
- import { dirname as dirname4, extname as extname2, join as join7, resolve as resolve3 } from "path";
3429
+ import { dirname as dirname3, extname as extname2, join as join6, resolve as resolve4 } from "path";
2906
3430
  import { fileURLToPath as fileURLToPath2 } from "url";
2907
3431
  function findServerProjectRoot(filePath, server) {
2908
3432
  if (server.root) {
2909
- return server.root(filePath) ?? dirname4(resolve3(filePath));
3433
+ return server.root(filePath) ?? dirname3(resolve4(filePath));
2910
3434
  }
2911
- return dirname4(resolve3(filePath));
3435
+ return dirname3(resolve4(filePath));
2912
3436
  }
2913
3437
  function uriToPath(uri) {
2914
3438
  return fileURLToPath2(uri);
@@ -2926,7 +3450,7 @@ function formatServerLookupError(result) {
2926
3450
  return `No LSP server configured for extension: ${result.extension}`;
2927
3451
  }
2928
3452
  async function withLspClient(filePath, fn) {
2929
- const absPath = resolve3(filePath);
3453
+ const absPath = resolve4(filePath);
2930
3454
  const ext = extname2(absPath);
2931
3455
  const result = findServerForExtension(ext);
2932
3456
  if (result.status !== "found") {
@@ -2937,7 +3461,7 @@ async function withLspClient(filePath, fn) {
2937
3461
  throw new Error(formatServerLookupError(result));
2938
3462
  }
2939
3463
  const server = result.server;
2940
- const root = findServerProjectRoot(absPath, server) ?? dirname4(absPath);
3464
+ const root = findServerProjectRoot(absPath, server) ?? dirname3(absPath);
2941
3465
  log("[lsp] withLspClient: acquiring client", {
2942
3466
  filePath: absPath,
2943
3467
  server: server.id,
@@ -3008,7 +3532,7 @@ function filterDiagnosticsBySeverity(diagnostics, severityFilter) {
3008
3532
  }
3009
3533
  function applyTextEditsToFile(filePath, edits) {
3010
3534
  try {
3011
- const content = readFileSync4(filePath, "utf-8");
3535
+ const content = readFileSync3(filePath, "utf-8");
3012
3536
  const lines = content.split("\n");
3013
3537
  const sortedEdits = [...edits].sort((a, b) => {
3014
3538
  if (b.range.start.line !== a.range.start.line) {
@@ -3092,7 +3616,7 @@ function applyWorkspaceEdit(edit) {
3092
3616
  try {
3093
3617
  const oldPath = uriToPath(change.oldUri);
3094
3618
  const newPath = uriToPath(change.newUri);
3095
- const content = readFileSync4(oldPath, "utf-8");
3619
+ const content = readFileSync3(oldPath, "utf-8");
3096
3620
  writeFileSync(newPath, content, "utf-8");
3097
3621
  unlinkSync2(oldPath);
3098
3622
  result.filesModified.push(newPath);
@@ -3154,16 +3678,18 @@ function formatApplyResult(result) {
3154
3678
 
3155
3679
  // src/tools/lsp/tools.ts
3156
3680
  var formatError = (e) => `Error: ${e instanceof Error ? e.message : String(e)}`;
3157
- var lsp_goto_definition = tool2({
3681
+ var lsp_goto_definition = defineTool({
3682
+ name: "lsp_goto_definition",
3158
3683
  description: "Jump to symbol definition. Find WHERE something is defined.",
3159
- args: {
3160
- filePath: tool2.schema.string().describe("Absolute path to the file"),
3161
- line: tool2.schema.number().min(1).describe("1-based line number"),
3162
- character: tool2.schema.number().min(0).describe("0-based character offset")
3163
- },
3164
- execute: async (args) => {
3684
+ input: z2.object({
3685
+ filePath: z2.string().describe("Absolute path to the file"),
3686
+ line: z2.number().min(1).describe("1-based line number"),
3687
+ character: z2.number().min(0).describe("0-based character offset")
3688
+ }),
3689
+ execute: async (args, context) => {
3165
3690
  try {
3166
3691
  const result = await withLspClient(args.filePath, async (client) => {
3692
+ context.signal.throwIfAborted();
3167
3693
  return await client.definition(
3168
3694
  args.filePath,
3169
3695
  args.line,
@@ -3183,17 +3709,19 @@ var lsp_goto_definition = tool2({
3183
3709
  }
3184
3710
  }
3185
3711
  });
3186
- var lsp_find_references = tool2({
3712
+ var lsp_find_references = defineTool({
3713
+ name: "lsp_find_references",
3187
3714
  description: "Find ALL usages/references of a symbol across the entire workspace.",
3188
- args: {
3189
- filePath: tool2.schema.string().describe("Absolute path to the file"),
3190
- line: tool2.schema.number().min(1).describe("1-based line number"),
3191
- character: tool2.schema.number().min(0).describe("0-based character offset"),
3192
- includeDeclaration: tool2.schema.boolean().optional().describe("Include the declaration itself")
3193
- },
3194
- execute: async (args) => {
3715
+ input: z2.object({
3716
+ filePath: z2.string().describe("Absolute path to the file"),
3717
+ line: z2.number().min(1).describe("1-based line number"),
3718
+ character: z2.number().min(0).describe("0-based character offset"),
3719
+ includeDeclaration: z2.boolean().optional().describe("Include the declaration itself")
3720
+ }),
3721
+ execute: async (args, context) => {
3195
3722
  try {
3196
3723
  const result = await withLspClient(args.filePath, async (client) => {
3724
+ context.signal.throwIfAborted();
3197
3725
  return await client.references(
3198
3726
  args.filePath,
3199
3727
  args.line,
@@ -3219,15 +3747,17 @@ var lsp_find_references = tool2({
3219
3747
  }
3220
3748
  }
3221
3749
  });
3222
- var lsp_diagnostics = tool2({
3750
+ var lsp_diagnostics = defineTool({
3751
+ name: "lsp_diagnostics",
3223
3752
  description: "Get errors, warnings, hints from language server BEFORE running build.",
3224
- args: {
3225
- filePath: tool2.schema.string().describe("Absolute path to the file"),
3226
- severity: tool2.schema.enum(["error", "warning", "information", "hint", "all"]).optional().describe("Filter by severity level")
3227
- },
3228
- execute: async (args) => {
3753
+ input: z2.object({
3754
+ filePath: z2.string().describe("Absolute path to the file"),
3755
+ severity: z2.enum(["error", "warning", "information", "hint", "all"]).optional().describe("Filter by severity level")
3756
+ }),
3757
+ execute: async (args, context) => {
3229
3758
  try {
3230
3759
  const result = await withLspClient(args.filePath, async (client) => {
3760
+ context.signal.throwIfAborted();
3231
3761
  return await client.diagnostics(args.filePath);
3232
3762
  });
3233
3763
  let diagnostics = [];
@@ -3257,17 +3787,19 @@ var lsp_diagnostics = tool2({
3257
3787
  }
3258
3788
  }
3259
3789
  });
3260
- var lsp_rename = tool2({
3790
+ var lsp_rename = defineTool({
3791
+ name: "lsp_rename",
3261
3792
  description: "Rename symbol across entire workspace. APPLIES changes to all files.",
3262
- args: {
3263
- filePath: tool2.schema.string().describe("Absolute path to the file"),
3264
- line: tool2.schema.number().min(1).describe("1-based line number"),
3265
- character: tool2.schema.number().min(0).describe("0-based character offset"),
3266
- newName: tool2.schema.string().describe("New symbol name")
3267
- },
3268
- execute: async (args) => {
3793
+ input: z2.object({
3794
+ filePath: z2.string().describe("Absolute path to the file"),
3795
+ line: z2.number().min(1).describe("1-based line number"),
3796
+ character: z2.number().min(0).describe("0-based character offset"),
3797
+ newName: z2.string().describe("New symbol name")
3798
+ }),
3799
+ execute: async (args, context) => {
3269
3800
  try {
3270
3801
  const edit = await withLspClient(args.filePath, async (client) => {
3802
+ context.signal.throwIfAborted();
3271
3803
  return await client.rename(
3272
3804
  args.filePath,
3273
3805
  args.line,
@@ -3275,6 +3807,7 @@ var lsp_rename = tool2({
3275
3807
  args.newName
3276
3808
  );
3277
3809
  });
3810
+ context.signal.throwIfAborted();
3278
3811
  const result = applyWorkspaceEdit(edit);
3279
3812
  return formatApplyResult(result);
3280
3813
  } catch (e) {
@@ -3283,375 +3816,37 @@ var lsp_rename = tool2({
3283
3816
  }
3284
3817
  });
3285
3818
 
3286
- // src/utils/tmux-session-manager.ts
3287
- var SESSION_TIMEOUT_MS = 10 * 60 * 1e3;
3288
- var SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
3289
- var TmuxSessionManager = class {
3290
- client;
3291
- tmuxConfig;
3292
- serverUrl;
3293
- sessions = /* @__PURE__ */ new Map();
3294
- pollInterval;
3295
- enabled = false;
3296
- constructor(ctx, tmuxConfig) {
3297
- this.client = ctx.client;
3298
- this.tmuxConfig = tmuxConfig;
3299
- const defaultPort = process.env.OPENCODE_PORT ?? "4096";
3300
- this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
3301
- this.enabled = tmuxConfig.enabled && isInsideTmux();
3302
- log("[tmux-session-manager] initialized", {
3303
- enabled: this.enabled,
3304
- tmuxConfig: this.tmuxConfig,
3305
- serverUrl: this.serverUrl
3306
- });
3307
- }
3308
- /**
3309
- * Handle session.created events.
3310
- * Spawns a tmux pane for child sessions (those with parentID).
3311
- */
3312
- async onSessionCreated(event) {
3313
- if (!this.enabled) return;
3314
- if (event.type !== "session.created") return;
3315
- const info = event.properties?.info;
3316
- if (!info?.id || !info?.parentID) {
3317
- return;
3318
- }
3319
- const sessionId = info.id;
3320
- const parentId = info.parentID;
3321
- const title = info.title ?? "Subagent";
3322
- if (this.sessions.has(sessionId)) {
3323
- log("[tmux-session-manager] session already tracked", { sessionId });
3324
- return;
3325
- }
3326
- log("[tmux-session-manager] child session created, spawning pane", {
3327
- sessionId,
3328
- parentId,
3329
- title
3330
- });
3331
- const paneResult = await spawnTmuxPane(
3332
- sessionId,
3333
- title,
3334
- this.tmuxConfig,
3335
- this.serverUrl
3336
- ).catch((err) => {
3337
- log("[tmux-session-manager] failed to spawn pane", {
3338
- error: String(err)
3339
- });
3340
- return { success: false, paneId: void 0 };
3341
- });
3342
- if (paneResult.success && paneResult.paneId) {
3343
- const now = Date.now();
3344
- this.sessions.set(sessionId, {
3345
- sessionId,
3346
- paneId: paneResult.paneId,
3347
- parentId,
3348
- title,
3349
- createdAt: now,
3350
- lastSeenAt: now
3351
- });
3352
- log("[tmux-session-manager] pane spawned", {
3353
- sessionId,
3354
- paneId: paneResult.paneId
3355
- });
3356
- this.startPolling();
3357
- }
3358
- }
3359
- /**
3360
- * Handle session.status events for completion detection.
3361
- * Uses session.status instead of deprecated session.idle.
3362
- *
3363
- * When a session becomes idle (completed), close its pane.
3364
- */
3365
- async onSessionStatus(event) {
3366
- if (!this.enabled) return;
3367
- if (event.type !== "session.status") return;
3368
- const sessionId = event.properties?.sessionID;
3369
- if (!sessionId) return;
3370
- if (event.properties?.status?.type === "idle") {
3371
- await this.closeSession(sessionId);
3372
- }
3373
- }
3374
- /**
3375
- * Handle session.deleted events.
3376
- * When a session is deleted, close its tmux pane immediately.
3377
- */
3378
- async onSessionDeleted(event) {
3379
- if (!this.enabled) return;
3380
- if (event.type !== "session.deleted") return;
3381
- const sessionId = event.properties?.sessionID;
3382
- if (!sessionId) return;
3383
- log("[tmux-session-manager] session deleted, closing pane", {
3384
- sessionId
3385
- });
3386
- await this.closeSession(sessionId);
3387
- }
3388
- startPolling() {
3389
- if (this.pollInterval) return;
3390
- this.pollInterval = setInterval(
3391
- () => this.pollSessions(),
3392
- POLL_INTERVAL_BACKGROUND_MS
3393
- );
3394
- log("[tmux-session-manager] polling started");
3395
- }
3396
- stopPolling() {
3397
- if (this.pollInterval) {
3398
- clearInterval(this.pollInterval);
3399
- this.pollInterval = void 0;
3400
- log("[tmux-session-manager] polling stopped");
3401
- }
3402
- }
3403
- /**
3404
- * Poll sessions for status updates (fallback for reliability).
3405
- * Also handles timeout and missing session detection.
3406
- */
3407
- async pollSessions() {
3408
- if (this.sessions.size === 0) {
3409
- this.stopPolling();
3410
- return;
3411
- }
3412
- try {
3413
- const statusResult = await this.client.session.status();
3414
- const allStatuses = statusResult.data ?? {};
3415
- const now = Date.now();
3416
- const sessionsToClose = [];
3417
- for (const [sessionId, tracked] of this.sessions.entries()) {
3418
- const status = allStatuses[sessionId];
3419
- const isIdle = status?.type === "idle";
3420
- if (status) {
3421
- tracked.lastSeenAt = now;
3422
- tracked.missingSince = void 0;
3423
- } else if (!tracked.missingSince) {
3424
- tracked.missingSince = now;
3425
- }
3426
- const missingTooLong = !!tracked.missingSince && now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
3427
- const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
3428
- if (isIdle || missingTooLong || isTimedOut) {
3429
- sessionsToClose.push(sessionId);
3430
- }
3431
- }
3432
- for (const sessionId of sessionsToClose) {
3433
- await this.closeSession(sessionId);
3434
- }
3435
- } catch (err) {
3436
- log("[tmux-session-manager] poll error", { error: String(err) });
3437
- }
3438
- }
3439
- async closeSession(sessionId) {
3440
- const tracked = this.sessions.get(sessionId);
3441
- if (!tracked) return;
3442
- log("[tmux-session-manager] closing session pane", {
3443
- sessionId,
3444
- paneId: tracked.paneId
3445
- });
3446
- await closeTmuxPane(tracked.paneId);
3447
- this.sessions.delete(sessionId);
3448
- if (this.sessions.size === 0) {
3449
- this.stopPolling();
3450
- }
3451
- }
3452
- /**
3453
- * Clean up all tracked sessions.
3454
- */
3455
- async cleanup() {
3456
- this.stopPolling();
3457
- if (this.sessions.size > 0) {
3458
- log("[tmux-session-manager] closing all panes", {
3459
- count: this.sessions.size
3460
- });
3461
- const closePromises = Array.from(this.sessions.values()).map(
3462
- (s) => closeTmuxPane(s.paneId).catch(
3463
- (err) => log("[tmux-session-manager] cleanup error for pane", {
3464
- paneId: s.paneId,
3465
- error: String(err)
3466
- })
3467
- )
3468
- );
3469
- await Promise.all(closePromises);
3470
- this.sessions.clear();
3471
- }
3472
- log("[tmux-session-manager] cleanup complete");
3473
- }
3474
- };
3819
+ // src/opencode/tools.ts
3820
+ async function registerTools(ctx, _deps) {
3821
+ await ctx.tool.transform((editor) => {
3822
+ editor.add(lsp_goto_definition);
3823
+ editor.add(lsp_find_references);
3824
+ editor.add(lsp_diagnostics);
3825
+ editor.add(lsp_rename);
3826
+ editor.add(ast_grep_search);
3827
+ editor.add(ast_grep_replace);
3828
+ });
3829
+ }
3475
3830
 
3476
3831
  // src/index.ts
3477
- var ThothAgents = async (ctx, _options) => {
3478
- const { client, directory } = ctx;
3479
- const config = loadPluginConfig(directory);
3480
- const agentDefs = createAgents(config);
3481
- const agents = renderOpenCodeAgentConfigs(config);
3482
- const packageRoot = fileURLToPath3(new URL("..", import.meta.url));
3483
- const thothInitCommand = createOpenCodeInitCommand({
3484
- projectRoot: directory,
3485
- packageRoot
3486
- });
3487
- const modelArrayMap = {};
3488
- for (const agentDef of agentDefs) {
3489
- if (agentDef._modelArray && agentDef._modelArray.length > 0) {
3490
- modelArrayMap[agentDef.name] = agentDef._modelArray;
3491
- }
3492
- }
3493
- const runtimeChains = {};
3494
- for (const agentDef of agentDefs) {
3495
- if (agentDef._modelArray?.length) {
3496
- runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
3497
- }
3498
- }
3499
- if (config.fallback?.enabled !== false) {
3500
- const chains = config.fallback?.chains ?? {};
3501
- for (const [agentName, chainModels] of Object.entries(chains)) {
3502
- if (!chainModels?.length) continue;
3503
- const existing = runtimeChains[agentName] ?? [];
3504
- const seen = new Set(existing);
3505
- for (const m of chainModels) {
3506
- if (!seen.has(m)) {
3507
- seen.add(m);
3508
- existing.push(m);
3509
- }
3510
- }
3511
- runtimeChains[agentName] = existing;
3512
- }
3513
- }
3514
- const tmuxConfig = {
3515
- enabled: config.tmux?.enabled ?? false,
3516
- layout: config.tmux?.layout ?? "main-vertical",
3517
- main_pane_size: config.tmux?.main_pane_size ?? 60
3518
- };
3519
- log("[plugin] initialized with tmux config", {
3520
- tmuxConfig,
3521
- rawTmuxConfig: config.tmux,
3522
- directory
3523
- });
3524
- if (tmuxConfig.enabled) {
3525
- startTmuxCheck();
3832
+ var plugin = {
3833
+ id: "thoth-agents",
3834
+ async setup(ctx) {
3835
+ const deps = loadOpenCodeDependencies(ctx);
3836
+ await registerAgents(ctx, deps);
3837
+ await registerTools(ctx, deps);
3838
+ await registerMcp(ctx, deps);
3839
+ await registerCommand(ctx, deps);
3840
+ await registerSkills(ctx, deps);
3841
+ await registerPermissions(ctx, deps);
3842
+ await registerProfileRuntime(ctx, deps);
3843
+ await registerProfileRpc(ctx, deps);
3844
+ await registerToolRpc(ctx, deps);
3845
+ await registerDelegationHooks(ctx, deps);
3846
+ await registerSessionHooks(ctx, deps);
3526
3847
  }
3527
- const mcps = createBuiltinMcps(config.disabled_mcps);
3528
- const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
3529
- const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
3530
- showStartupToast: true
3531
- });
3532
- const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
3533
- const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
3534
- const oracleGateVerdictHook = createOracleGateVerdictHook(ctx);
3535
- const foregroundFallback = new ForegroundFallbackManager(
3536
- client,
3537
- runtimeChains,
3538
- config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0
3539
- );
3540
- return {
3541
- name: "thoth-agents",
3542
- agent: agents,
3543
- tool: {
3544
- lsp_goto_definition,
3545
- lsp_find_references,
3546
- lsp_diagnostics,
3547
- lsp_rename,
3548
- ast_grep_search,
3549
- ast_grep_replace
3550
- },
3551
- mcp: mcps,
3552
- config: async (opencodeConfig) => {
3553
- const lspConfig = opencodeConfig.lsp;
3554
- setUserLspConfig(lspConfig);
3555
- const commands = opencodeConfig.command ?? {};
3556
- if (!commands["thoth-init"]) {
3557
- commands["thoth-init"] = thothInitCommand;
3558
- }
3559
- opencodeConfig.command = commands;
3560
- if (config.setDefaultAgent !== false && !opencodeConfig.default_agent) {
3561
- opencodeConfig.default_agent = "orchestrator";
3562
- }
3563
- if (!opencodeConfig.agent) {
3564
- opencodeConfig.agent = { ...agents };
3565
- } else {
3566
- for (const [name, pluginAgent] of Object.entries(agents)) {
3567
- const existing = opencodeConfig.agent[name];
3568
- if (existing) {
3569
- opencodeConfig.agent[name] = {
3570
- ...pluginAgent,
3571
- ...existing
3572
- };
3573
- } else {
3574
- opencodeConfig.agent[name] = {
3575
- ...pluginAgent
3576
- };
3577
- }
3578
- }
3579
- }
3580
- const configAgent = opencodeConfig.agent;
3581
- const fallbackChainsEnabled = config.fallback?.enabled !== false;
3582
- const fallbackChains = fallbackChainsEnabled ? config.fallback?.chains ?? {} : {};
3583
- const effectiveArrays = {};
3584
- for (const [agentName, models] of Object.entries(modelArrayMap)) {
3585
- effectiveArrays[agentName] = [...models];
3586
- }
3587
- for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
3588
- if (!chainModels || chainModels.length === 0) continue;
3589
- if (!effectiveArrays[agentName]) {
3590
- const entry = configAgent[agentName];
3591
- const currentModel = typeof entry?.model === "string" ? entry.model : void 0;
3592
- effectiveArrays[agentName] = currentModel ? [{ id: currentModel }] : [];
3593
- }
3594
- const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
3595
- for (const chainModel of chainModels) {
3596
- if (!seen.has(chainModel)) {
3597
- seen.add(chainModel);
3598
- effectiveArrays[agentName].push({ id: chainModel });
3599
- }
3600
- }
3601
- }
3602
- if (Object.keys(effectiveArrays).length > 0) {
3603
- for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
3604
- if (modelArray.length === 0) continue;
3605
- const chosen = modelArray[0];
3606
- const entry = configAgent[agentName];
3607
- if (entry) {
3608
- entry.model = chosen.id;
3609
- if (chosen.variant) {
3610
- entry.variant = chosen.variant;
3611
- }
3612
- }
3613
- log("[plugin] resolved model from array", {
3614
- agent: agentName,
3615
- model: chosen.id,
3616
- variant: chosen.variant
3617
- });
3618
- }
3619
- }
3620
- const configMcp = opencodeConfig.mcp;
3621
- if (!configMcp) {
3622
- opencodeConfig.mcp = { ...mcps };
3623
- } else {
3624
- Object.assign(configMcp, mcps);
3625
- }
3626
- },
3627
- event: async (input) => {
3628
- await foregroundFallback.handleEvent(input.event);
3629
- await autoUpdateChecker.event(input);
3630
- await tmuxSessionManager.onSessionCreated(
3631
- input.event
3632
- );
3633
- await tmuxSessionManager.onSessionStatus(
3634
- input.event
3635
- );
3636
- await tmuxSessionManager.onSessionDeleted(
3637
- input.event
3638
- );
3639
- },
3640
- // Post-tool hooks: delegation retry, JSON recovery, then Oracle gate verdicts.
3641
- "tool.execute.after": async (input, output) => {
3642
- await delegateTaskRetryHook["tool.execute.after"](
3643
- input,
3644
- output
3645
- );
3646
- await jsonErrorRecoveryHook["tool.execute.after"](
3647
- input,
3648
- output
3649
- );
3650
- await oracleGateVerdictHook["tool.execute.after"](input, output);
3651
- }
3652
- };
3653
3848
  };
3654
- var src_default = ThothAgents;
3849
+ var src_default = plugin;
3655
3850
  export {
3656
3851
  src_default as default
3657
3852
  };