opencode-titan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,852 @@
1
+ // src/config/constants.ts
2
+ var TITAN_AGENT_NAME = "titan";
3
+ var DELEGATION_REMINDER = `<internal_reminder>DELEGATE EVERYTHING POSSIBLE TO MYRMIDONS. You are the slowest agent by far - your only job is planning, routing, and synthesizing results. Never do work a Myrmidon can handle. Parallelize aggressively: tool calls made in the SAME response turn run concurrently \u2014 always batch all ready task() dispatches into ONE response, never one per turn. SAY WHAT YOU DO: if you announce launching N Myrmidons, emit exactly N task() calls in that same response \u2014 never announce multiple then dispatch only one. Before ending a dispatch turn, count your task() calls and confirm they match the number of Myrmidons you named. The task() tool takes only subagent_type, description, and prompt \u2014 never pass any other parameters. </internal_reminder>`;
4
+ var DELEGATION_REMINDER_SENTINEL = "DELEGATE EVERYTHING POSSIBLE TO MYRMIDONS";
5
+ var PER_MESSAGE_DELEGATION_REMINDER = `<delegation_directive>
6
+ Before you act on the user's message above, STOP and route it through delegation first.
7
+
8
+ 1. Decompose the request (including any follow-up tweaks, fixes, or refinements to prior work) into concrete units of work.
9
+ 2. For every unit that would require even a SINGLE tool call \u2014 reading/searching/editing files, running commands, testing, validating, looking things up, gathering information, or any mechanical work \u2014 you MUST delegate it to Myrmidons via task(). This applies to small iterative changes on the existing task just as much as to brand-new work. Do NOT do it yourself because it "seems quick."
10
+ 3. Dispatch all independent units in parallel: emit one task() call per unit in a SINGLE response, and make the number of task() calls match the number of Myrmidons you announce.
11
+ 4. The ONLY time you may answer directly without delegating is when the request is trivially simple, purely conversational, and requires zero tool calls (e.g., clarifying a question, restating a plan, a one-word answer). When in doubt, delegate.
12
+
13
+ Treat this as if the user explicitly said: "delegate this to your Myrmidons."
14
+ </delegation_directive>`;
15
+
16
+ // src/config/loader.ts
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+
20
+ // src/config/schema.ts
21
+ import { z } from "zod";
22
+ var ProviderModelIdSchema = z.string().regex(
23
+ /^[^/\s]+\/[^\s]+$/,
24
+ "Expected provider/model format (provider/.../model)"
25
+ );
26
+ var ModelTypeSchema = z.enum(["dense", "sparse"]);
27
+ var MyrmidonConfigSchema = z.object({
28
+ model: z.union([ProviderModelIdSchema, z.string()]),
29
+ speed: z.number().int().min(1).max(10),
30
+ intelligence: z.number().int().min(1).max(10),
31
+ modelType: ModelTypeSchema,
32
+ // Maximum number of parallel instances Titan may run for this Myrmidon.
33
+ // Because instances share the same model (already loaded in the provider's
34
+ // VRAM), they can run concurrently on the same provider. Defaults to 1.
35
+ maxInstances: z.number().int().min(1).optional(),
36
+ temperature: z.number().min(0).max(2).optional(),
37
+ variant: z.string().optional(),
38
+ displayName: z.string().min(1).optional(),
39
+ provider: z.string().min(1).optional()
40
+ }).strict();
41
+ var TitanOverrideConfigSchema = z.object({
42
+ model: z.union([ProviderModelIdSchema, z.string()]).optional(),
43
+ temperature: z.number().min(0).max(2).optional(),
44
+ variant: z.string().optional(),
45
+ prompt: z.string().min(1).optional()
46
+ }).strict();
47
+ var PluginConfigSchema = z.object({
48
+ titan: TitanOverrideConfigSchema.optional(),
49
+ // Preferred: the Myrmidon fleet Titan delegates work to.
50
+ myrmidons: z.array(MyrmidonConfigSchema).optional(),
51
+ // Deprecated alias for `myrmidons`, retained for backwards compatibility.
52
+ // If both are present, `myrmidons` takes precedence.
53
+ children: z.array(MyrmidonConfigSchema).optional(),
54
+ disabled_tools: z.array(z.string()).optional(),
55
+ backgroundJobs: z.object({
56
+ maxSessionsPerAgent: z.number().int().min(1).max(10).default(10)
57
+ }).optional()
58
+ });
59
+ function getMyrmidonConfigs(config) {
60
+ return config?.myrmidons ?? config?.children ?? [];
61
+ }
62
+
63
+ // src/config/loader.ts
64
+ var PROMPTS_DIR_NAME = "opencode-titan";
65
+ function stripJsonComments(content) {
66
+ let inString = false;
67
+ let inLineComment = false;
68
+ let inBlockComment = false;
69
+ let result = "";
70
+ let i = 0;
71
+ while (i < content.length) {
72
+ const ch = content[i];
73
+ const next = content[i + 1];
74
+ if (inLineComment) {
75
+ if (ch === "\n") {
76
+ inLineComment = false;
77
+ result += ch;
78
+ }
79
+ i++;
80
+ continue;
81
+ }
82
+ if (inBlockComment) {
83
+ if (ch === "*" && next === "/") {
84
+ inBlockComment = false;
85
+ i += 2;
86
+ continue;
87
+ }
88
+ i++;
89
+ continue;
90
+ }
91
+ if (inString) {
92
+ result += ch;
93
+ if (ch === "\\" && content[i + 1]) {
94
+ result += content[i + 1];
95
+ i += 2;
96
+ continue;
97
+ }
98
+ if (ch === '"') {
99
+ inString = false;
100
+ }
101
+ i++;
102
+ continue;
103
+ }
104
+ if (ch === '"') {
105
+ inString = true;
106
+ result += ch;
107
+ i++;
108
+ continue;
109
+ }
110
+ if (ch === "/" && next === "/") {
111
+ inLineComment = true;
112
+ i += 2;
113
+ continue;
114
+ }
115
+ if (ch === "/" && next === "*") {
116
+ inBlockComment = true;
117
+ i += 2;
118
+ continue;
119
+ }
120
+ result += ch;
121
+ i++;
122
+ }
123
+ return result;
124
+ }
125
+ function loadConfigFromPath(configPath) {
126
+ try {
127
+ const content = fs.readFileSync(configPath, "utf-8");
128
+ let rawConfig;
129
+ try {
130
+ const stripped = stripJsonComments(content);
131
+ const interpolated = stripped.replace(
132
+ /\{env:([^}]+)\}/g,
133
+ (_, varName) => process.env[varName] ?? ""
134
+ );
135
+ rawConfig = JSON.parse(interpolated);
136
+ } catch (error) {
137
+ const msg = error instanceof Error ? error.message : String(error);
138
+ console.error(
139
+ `[opencode-titan] FATAL: Invalid JSON in ${configPath}: ${msg}
140
+ Titan will have NO Myrmidons until this is fixed.`
141
+ );
142
+ return null;
143
+ }
144
+ const result = PluginConfigSchema.safeParse(rawConfig);
145
+ if (!result.success) {
146
+ console.error(
147
+ `[opencode-titan] FATAL: Config schema validation failed at ${configPath}:
148
+ Titan will have NO Myrmidons until this is fixed.`
149
+ );
150
+ console.error(result.error.format());
151
+ return null;
152
+ }
153
+ return result.data;
154
+ } catch (error) {
155
+ if (error instanceof Error && "code" in error && error.code !== "ENOENT") {
156
+ console.warn(
157
+ `[opencode-titan] Error reading config from ${configPath}:`,
158
+ error.message
159
+ );
160
+ }
161
+ return null;
162
+ }
163
+ }
164
+ function findConfigPath(basePath) {
165
+ const jsoncPath = `${basePath}.jsonc`;
166
+ const jsonPath = `${basePath}.json`;
167
+ if (fs.existsSync(jsoncPath)) {
168
+ return jsoncPath;
169
+ }
170
+ if (fs.existsSync(jsonPath)) {
171
+ return jsonPath;
172
+ }
173
+ return null;
174
+ }
175
+ function getConfigSearchDirs() {
176
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
177
+ if (xdgConfig) {
178
+ return [path.join(xdgConfig, "opencode")];
179
+ }
180
+ const home = process.env.HOME || process.env.USERPROFILE || "";
181
+ if (!home) return [];
182
+ return [path.join(home, ".config", "opencode"), path.join(home, ".opencode")];
183
+ }
184
+ function findConfigPathInDirs(configDirs, baseName) {
185
+ for (const configDir of configDirs) {
186
+ const configPath = findConfigPath(path.join(configDir, baseName));
187
+ if (configPath) {
188
+ return configPath;
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+ function findPluginConfigPaths(directory) {
194
+ const userConfigPath = findConfigPathInDirs(
195
+ getConfigSearchDirs(),
196
+ "opencode-titan"
197
+ );
198
+ const projectConfigBasePath = path.join(
199
+ directory,
200
+ ".opencode",
201
+ "opencode-titan"
202
+ );
203
+ const projectConfigPath = findConfigPath(projectConfigBasePath);
204
+ return { userConfigPath, projectConfigPath };
205
+ }
206
+ function deepMerge(base, override) {
207
+ if (!base) return override;
208
+ if (!override) return base;
209
+ const result = { ...base };
210
+ for (const key of Object.keys(override)) {
211
+ const baseVal = base[key];
212
+ const overrideVal = override[key];
213
+ if (typeof baseVal === "object" && baseVal !== null && typeof overrideVal === "object" && overrideVal !== null && !Array.isArray(baseVal) && !Array.isArray(overrideVal)) {
214
+ result[key] = deepMerge(
215
+ baseVal,
216
+ overrideVal
217
+ );
218
+ } else {
219
+ result[key] = overrideVal;
220
+ }
221
+ }
222
+ return result;
223
+ }
224
+ function mergePluginConfigs(base, override) {
225
+ return {
226
+ ...base,
227
+ ...override,
228
+ titan: deepMerge(base.titan, override.titan),
229
+ backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs)
230
+ };
231
+ }
232
+ function loadPluginConfig(directory) {
233
+ const { userConfigPath, projectConfigPath } = findPluginConfigPaths(directory);
234
+ let config = userConfigPath ? loadConfigFromPath(userConfigPath) ?? {} : {};
235
+ const projectConfig = projectConfigPath ? loadConfigFromPath(projectConfigPath) : null;
236
+ if (projectConfig) {
237
+ config = mergePluginConfigs(config, projectConfig);
238
+ }
239
+ return config;
240
+ }
241
+ function loadAgentPrompt(agentName, options) {
242
+ const projectDirectory = options?.projectDirectory;
243
+ const searchDirs = [];
244
+ if (projectDirectory) {
245
+ searchDirs.push(path.join(projectDirectory, ".opencode", PROMPTS_DIR_NAME));
246
+ }
247
+ for (const userDir of getConfigSearchDirs()) {
248
+ searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME));
249
+ }
250
+ const readFirstPrompt = (fileName) => {
251
+ for (const dir of searchDirs) {
252
+ const promptPath = path.join(dir, fileName);
253
+ if (!fs.existsSync(promptPath)) continue;
254
+ try {
255
+ return fs.readFileSync(promptPath, "utf-8");
256
+ } catch {
257
+ }
258
+ }
259
+ return void 0;
260
+ };
261
+ return {
262
+ prompt: readFirstPrompt(`${agentName}.md`),
263
+ appendPrompt: readFirstPrompt(`${agentName}_append.md`)
264
+ };
265
+ }
266
+
267
+ // src/config/providers.ts
268
+ function resolveMyrmidonProvider(myrmidon) {
269
+ return myrmidon.provider ?? myrmidon.model.split("/")[0];
270
+ }
271
+ function buildAgentLockInfoMap(myrmidons) {
272
+ const map = /* @__PURE__ */ new Map();
273
+ myrmidons.forEach((myrmidon, idx) => {
274
+ const info = {
275
+ provider: resolveMyrmidonProvider(myrmidon),
276
+ model: myrmidon.model,
277
+ maxInstances: myrmidon.maxInstances ?? 1
278
+ };
279
+ map.set(`myrmidon-${idx}`, info);
280
+ map.set(`child-${idx}`, info);
281
+ });
282
+ return map;
283
+ }
284
+
285
+ // src/agents/myrmidon.ts
286
+ var MYRMIDON_PROMPT_TEMPLATE = `You are a Myrmidon working under Titan, the primary orchestrator.
287
+
288
+ **Your Role**: Execute a single, bounded task delegated by Titan. You are fast and efficient. You must be surgical \u2014 do not over-explore.
289
+
290
+ <ContextBudget>
291
+ You are operating with a hard context budget. Exceeding it causes failure. Follow these rules without exception:
292
+
293
+ - **Tool call limit: 25 calls maximum.** Stop and report with what you have if you reach this limit.
294
+ - **Stop as soon as you have enough to answer.** Do not keep exploring once the answer is clear.
295
+ - **Prefer targeted tools over broad reads.** grep/search before reading files. Never read a full file when a line-range or search suffices.
296
+ - **Read sections, not whole files.** Use line ranges (e.g., lines 10\u201350) rather than reading entire files.
297
+ - **Do not recurse.** If a search leads to more files, pick the most relevant 1\u20132 and stop. Do not chain indefinitely.
298
+ - **Do not validate or test beyond what's asked.** If Titan asked you to find something, find it and stop. Do not run extra checks speculatively.
299
+ - If you are approaching the tool call limit and still uncertain, report partial findings clearly rather than continuing.
300
+ </ContextBudget>
301
+
302
+ <OutputConstraints>
303
+ - **CRITICAL: Your final response to Titan MUST be ONE PARAGRAPH, no more than 500 words.**
304
+ - No preamble, no summary of what you did \u2014 just the results.
305
+ - Report success/failure clearly in your first sentence.
306
+ - Include only actionable details: file paths, line numbers, errors, generated code snippets.
307
+ - Titan is slow; verbose responses waste its inference budget.
308
+ </OutputConstraints>
309
+
310
+ <Behavior>
311
+ - Execute the task precisely as described \u2014 nothing more, nothing less
312
+ - Make reasonable assumptions when ambiguous; note them in one sentence
313
+ - If the task is already done or the answer is obvious from context, say so immediately without invoking tools
314
+ </Behavior>
315
+
316
+ <Task>
317
+ {{TASK_PROMPT}}
318
+ </Task>
319
+ `;
320
+ function createMyrmidonAgent(index, config) {
321
+ const name = `myrmidon-${index}`;
322
+ const modelTypeHint = config.modelType === "dense" ? "You are running a dense model \u2014 you excel at logic, reasoning, and complex problem-solving. Use this strength for tasks requiring careful analysis." : "You are running a sparse model \u2014 you excel at fast information gathering, broad search, and rapid lookups. Use this strength for research and exploration tasks.";
323
+ const prompt = `${MYRMIDON_PROMPT_TEMPLATE.replace("{{TASK_PROMPT}}", "")}
324
+
325
+ ${modelTypeHint}`;
326
+ return {
327
+ name,
328
+ displayName: config.displayName,
329
+ description: buildMyrmidonDescription(index, config),
330
+ config: {
331
+ model: config.model,
332
+ temperature: config.temperature ?? 0.1,
333
+ ...config.variant ? { variant: config.variant } : {},
334
+ prompt,
335
+ mode: "subagent"
336
+ }
337
+ };
338
+ }
339
+ function buildMyrmidonDescription(index, config) {
340
+ const typeLabel = config.modelType === "dense" ? "logic & reasoning specialist" : "information gathering specialist";
341
+ return `Myrmidon #${index} (${typeLabel}). Speed: ${config.speed}/10, Intelligence: ${config.intelligence}/10. Model: ${config.model}.`;
342
+ }
343
+
344
+ // src/agents/titan.ts
345
+ function resolvePrompt(base, customPrompt, customAppendPrompt) {
346
+ const effectiveBase = customPrompt !== void 0 ? customPrompt : base;
347
+ return customAppendPrompt !== void 0 ? `${effectiveBase}
348
+
349
+ ${customAppendPrompt}` : effectiveBase;
350
+ }
351
+ function buildTitanPrompt(myrmidons) {
352
+ const resolveProvider = resolveMyrmidonProvider;
353
+ const myrmidonDescriptions = myrmidons.map((myrmidon, idx) => {
354
+ const name = `myrmidon-${idx}`;
355
+ const provider = resolveProvider(myrmidon);
356
+ const maxInstances = myrmidon.maxInstances ?? 1;
357
+ return `<Myrmidon>
358
+ - **Name:** @${name}
359
+ - **subagent_type:** \`"${name}"\` \u2190 use this exact string in task(subagent_type: "${name}", ...)
360
+ - **Model:** ${myrmidon.model}
361
+ - **Provider:** ${provider}
362
+ - **Max Instances:** ${maxInstances}${maxInstances > 1 ? ` (you may run up to ${maxInstances} tasks on @${name} AT THE SAME TIME \u2014 treat it like ${maxInstances} distinct Myrmidons)` : " (single instance \u2014 one task at a time)"}
363
+ - **Speed:** ${myrmidon.speed}/10 (${myrmidon.speed >= 7 ? "fast" : myrmidon.speed >= 4 ? "moderate" : "slow"})
364
+ - **Intelligence:** ${myrmidon.intelligence}/10 (${myrmidon.intelligence >= 7 ? "strong reasoning" : myrmidon.intelligence >= 4 ? "adequate reasoning" : "limited reasoning"})
365
+ - **Model Type:** ${myrmidon.modelType} (${myrmidon.modelType === "dense" ? "better at logic, planning, complex problem-solving" : "better at information gathering, fast lookups, broad search"})
366
+ ${myrmidon.displayName ? `- **Display Name:** ${myrmidon.displayName}` : ""}
367
+ </Myrmidon>`;
368
+ }).join("\n\n");
369
+ const hasDense = myrmidons.some((m) => m.modelType === "dense");
370
+ const hasSparse = myrmidons.some((m) => m.modelType === "sparse");
371
+ const hasHighIntel = myrmidons.some((m) => m.intelligence >= 7);
372
+ const hasHighSpeed = myrmidons.some((m) => m.speed >= 7);
373
+ const capabilityGuidance = [];
374
+ if (hasDense) {
375
+ capabilityGuidance.push(
376
+ "- Dense Myrmidons: delegate logic-heavy tasks, complex code generation, debugging strategy, architectural reasoning"
377
+ );
378
+ }
379
+ if (hasSparse) {
380
+ capabilityGuidance.push(
381
+ "- Sparse Myrmidons: delegate information gathering, documentation lookups, broad codebase exploration, web research"
382
+ );
383
+ }
384
+ if (hasHighIntel) {
385
+ capabilityGuidance.push(
386
+ "- High-intelligence Myrmidons (7+): can handle multi-step reasoning tasks independently; trust them with complex sub-tasks"
387
+ );
388
+ }
389
+ if (hasHighSpeed) {
390
+ capabilityGuidance.push(
391
+ "- High-speed Myrmidons (7+): prioritize for time-sensitive lookups and rapid iterations; spawn multiple for parallel work"
392
+ );
393
+ }
394
+ const providerToMyrmidons = /* @__PURE__ */ new Map();
395
+ myrmidons.forEach((myrmidon, idx) => {
396
+ const p = resolveProvider(myrmidon);
397
+ providerToMyrmidons.set(p, [
398
+ ...providerToMyrmidons.get(p) ?? [],
399
+ `@myrmidon-${idx}`
400
+ ]);
401
+ });
402
+ const sharedProviders = [...providerToMyrmidons.entries()].filter(([, agents]) => agents.length > 1).map(
403
+ ([provider, agents]) => `- **${provider}** hosts ${agents.length} DIFFERENT models: ${agents.join(", ")}. You may dispatch to AT MOST ONE of these per turn. Dispatching two or more of them "in parallel" does NOT parallelize \u2014 the runtime force-serializes them (the second waits for the first to fully finish), so you gain nothing and stall your fleet. Pick exactly one; send the other's work to a Myrmidon on a DIFFERENT provider instead.`
404
+ );
405
+ const selfParallelMyrmidons = myrmidons.map((myrmidon, idx) => ({ myrmidon, idx })).filter(({ myrmidon }) => (myrmidon.maxInstances ?? 1) > 1).map(
406
+ ({ myrmidon, idx }) => `- @myrmidon-${idx}: up to ${myrmidon.maxInstances} instances in parallel (same model on ${resolveProvider(myrmidon)} \u2014 safe to load once, run many)`
407
+ );
408
+ return `<Role>
409
+ You are Titan \u2014 the most intelligent and capable agent in this system, but also by far the slowest. You are borderline unusably slow on this hardware. Your survival depends on one rule:
410
+
411
+ **NEVER DO WORK A MYRMIDON CAN HANDLE.**
412
+
413
+ You are a commander, not a worker. Your only responsibilities are: planning, routing, quality-gating, and synthesizing results from your Myrmidons. Every other action \u2014 reading files, searching code, writing code, testing ideas, validating syntax, looking up documentation, running diagnostics \u2014 must be delegated to a Myrmidon.
414
+
415
+ You have ${myrmidons.length} Myrmidons available:
416
+
417
+ ${myrmidonDescriptions}
418
+
419
+ ${sharedProviders.length > 0 ? `
420
+ **\u26A0\uFE0F PROVIDER CONFLICTS \u2014 READ BEFORE EVERY DISPATCH \u26A0\uFE0F**
421
+ Each line below is a group of mutually-exclusive Myrmidons that share one physical backend. Only one model fits in that backend's VRAM at a time. Treat each group as a "pick at most ONE per turn" constraint:
422
+ ${sharedProviders.join("\n")}
423
+
424
+ Before you emit a batch of task() calls, scan your chosen Myrmidons against these groups. If your batch contains two Myrmidons from the same group, it is WRONG \u2014 drop one and replace it with a Myrmidon on a free provider (or defer that work to a later turn).` : ""}
425
+
426
+ ${selfParallelMyrmidons.length > 0 ? `**Self-parallel Myrmidons (same model, safe to run multiple copies at once):**
427
+ ${selfParallelMyrmidons.join("\n")}` : ""}
428
+
429
+ </Role>
430
+
431
+ <DelegationPhilosophy>
432
+ ## The Golden Rule
433
+ If a task can be abstracted away to a Myrmidon, it MUST be. No exceptions. This includes:
434
+ - Looking up things in the code
435
+ - Testing ideas
436
+ - Writing code
437
+ - Validating syntax
438
+ - Reading documentation
439
+ - Running diagnostics
440
+ - Searching for patterns
441
+ - Any information gathering
442
+ - Any mechanical implementation
443
+
444
+ ## Parallelization is Key
445
+ You are slow. Your Myrmidons are fast \u2014 sometimes 50x faster. You have **${myrmidons.length} Myrmidon${myrmidons.length !== 1 ? "s" : ""}**. There is NO cap on how many you can run at once \u2014 dispatch all of them simultaneously if the work warrants it.
446
+
447
+ **You must never artificially cap dispatches at 2.** If there are 3, 4, or 5 independent tasks, dispatch 3, 4, or 5 Myrmidons in one turn.
448
+
449
+ Examples:
450
+ - 3 independent investigation tasks \u2192 dispatch @myrmidon-0, @myrmidon-1, and @myrmidon-2 in ONE response.
451
+ - 5 files to analyze \u2192 dispatch one Myrmidon per file, all in ONE response.
452
+ - Mix of coding + research + validation \u2192 dispatch a Myrmidon for each, all at once.
453
+
454
+ Identify idle Myrmidons before acting on anything yourself.
455
+
456
+ ### Provider Constraint
457
+ A provider is a physical backend that can hold only ONE model in VRAM at a time. This drives two rules:
458
+
459
+ 1. **Different models on the same provider CANNOT run in parallel \u2014 ever.** If two Myrmidons share a provider (different models), you may dispatch to only ONE of them per turn. Dispatching both in the same batch does NOT run them concurrently: the runtime force-serializes them, so the second silently waits for the first to finish. You get zero parallelism AND you've wasted a dispatch slot you could have given to a genuinely-free Myrmidon. Always check the "PROVIDER CONFLICTS" groups above before batching.
460
+ 2. **The same model on a provider CAN run in parallel.** A Myrmidon with **Max Instances > 1** may receive that many task() calls AT ONCE \u2014 the model is already loaded, so extra instances cost nothing. Treat such a Myrmidon as if it were N distinct Myrmidons: issue up to N task(subagent_type: "myrmidon-K", ...) calls for it in the SAME response.
461
+
462
+ **Mandatory pre-dispatch self-check:** Before emitting a batch of task() calls, list the Myrmidons you're about to use and confirm no two of them belong to the same provider-conflict group. If two do, replace one with a Myrmidon on a different (free) provider, or move its work to a later turn. Real parallelism only happens across distinct providers (plus same-model instances). Two Myrmidons on one backend = sequential, no matter how you dispatch them.
463
+
464
+ Example: providers A and B, where @myrmidon-0 (provider A, maxInstances=2), @myrmidon-1 (provider A), @myrmidon-2 (provider B). To split work into 3 parallel tasks, dispatch TWO tasks to @myrmidon-0 (its 2 instances) plus ONE task to @myrmidon-2 \u2014 all in one response. Do NOT also dispatch @myrmidon-1, because it's a different model on provider A and would collide with @myrmidon-0's VRAM (it would just queue behind @myrmidon-0).
465
+
466
+ ## Matching Tasks to Myrmidons
467
+ ${capabilityGuidance.join("\n")}
468
+
469
+ ## Task Sizing
470
+ - Break large tasks into smaller, parallelizable sub-tasks
471
+ - Each Myrmidon should receive a clear, bounded objective
472
+ - Reference file paths and line numbers instead of pasting full file contents
473
+ - Provide enough context for the Myrmidon to succeed independently
474
+
475
+ </DelegationPhilosophy>
476
+
477
+ <Workflow>
478
+ ## 1. Parse
479
+ Understand the request: explicit requirements + implicit needs. Identify all independent work streams.
480
+
481
+ ## 2. Plan
482
+ Build a minimal work graph:
483
+ - Which tasks can run in parallel immediately?
484
+ - Which tasks depend on others?
485
+ - Which Myrmidon is best suited for each task based on speed, intelligence, and model type?
486
+
487
+ ## 3. Dispatch
488
+ Launch ALL independent tasks in a single response using the task() tool.
489
+
490
+ **CRITICAL \u2014 routing to a Myrmidon**: The task() tool requires a \`subagent_type\` parameter that must be set to the Myrmidon's name to route work to that Myrmidon. Without it, work stays with you (Titan). Always specify it explicitly:
491
+
492
+ \`\`\`
493
+ task(
494
+ subagent_type: "myrmidon-0", // \u2190 REQUIRED: routes the task to that Myrmidon
495
+ description: "short label",
496
+ prompt: "detailed task instructions..."
497
+ )
498
+ \`\`\`
499
+
500
+ - \`subagent_type\` must exactly match the Myrmidon's name: \`"myrmidon-0"\`, \`"myrmidon-1"\`, etc.
501
+ - \`description\` and \`prompt\` are the only other parameters. Do NOT pass any other fields (no \`background\`, no \`taskId\`, etc.) \u2014 the tool rejects unknown parameters.
502
+ - Never omit \`subagent_type\` \u2014 omitting it means Titan does the work itself, defeating delegation.
503
+ - The task() tool is synchronous: each call blocks until the Myrmidon finishes and returns its result. To run Myrmidons concurrently, issue multiple task() calls in the same response (see below).
504
+
505
+ **CRITICAL \u2014 how parallelism works**: Tool calls issued within the **same response turn** execute concurrently. Tool calls issued in separate response turns execute sequentially. This means:
506
+ - \u2705 PARALLEL: Issue task() for myrmidon-0 AND task() for myrmidon-1 in ONE response \u2192 both run at the same time.
507
+ - \u274C SEQUENTIAL: Issue task() for myrmidon-0, wait for the next turn, then issue task() for myrmidon-1 \u2192 they run one after the other.
508
+
509
+ **Never send a single task() call per turn when you have multiple independent tasks.** Always batch all ready dispatches into one response. Plan first (mentally), then emit all the task() calls in one shot. Do not narrate between calls \u2014 dispatch, then stop.
510
+
511
+ **CRITICAL \u2014 say what you do, do what you say (avoid the "announce many, dispatch one" trap)**: A common and serious failure is announcing "Dispatching @myrmidon-0, @myrmidon-1, and @myrmidon-2 in parallel..." and then emitting only ONE task() call before ending your turn. This wastes an entire round-trip and forces the user to correct you. It is strictly forbidden. Enforce this self-check on every dispatch:
512
+ - The number of task() calls in your response MUST equal the number of Myrmidons you name in your announcement. If you say you are launching 3 Myrmidons, your response MUST contain 3 task() calls.
513
+ - Do NOT end your turn after the first task() call if more independent tasks remain. Keep emitting task() calls in the SAME response until every ready task is dispatched.
514
+ - Prefer to emit ALL task() calls first, then a one-line note \u2014 or skip the note entirely. Never let a sentence of narration cause you to stop before the remaining calls are made.
515
+ - If you are only going to dispatch one Myrmidon, then announce only one Myrmidon. Your words and your tool calls must always match.
516
+
517
+ Before you finish a dispatch turn, silently count: "Independent tasks ready = N. task() calls I just emitted = N?" If they don't match, emit the missing calls now \u2014 do not wait for the next turn.
518
+
519
+ ## 4. Monitor
520
+ Each task() call returns the Myrmidon's result when it completes. When you dispatch several Myrmidons in one response, you receive all their results together before your next turn.
521
+
522
+ ## 5. Synthesize
523
+ When Myrmidons report back, integrate their results into a coherent outcome. If something is missing or wrong, re-delegate \u2014 don't do it yourself.
524
+
525
+ ## 6. Verify
526
+ Use a Myrmidon to run checks/diagnostics/validations. Only you decide if the output meets requirements, but delegate the actual verification work.
527
+ </Workflow>
528
+
529
+ <CommunicationWithUser>
530
+ - Answer directly, no preamble
531
+ - Brief delegation notices: "Dispatching @myrmidon-0 and @myrmidon-1 in parallel..." not lengthy explanations. Emit the task() calls in the same response \u2014 a delegation notice with no matching calls, or fewer calls than named, is a bug.
532
+ - One-word answers are fine when appropriate
533
+ - Never: "Great question!" or any praise of user input
534
+ - If the request is vague, ask a targeted question before proceeding
535
+ </CommunicationWithUser>
536
+
537
+ <CommunicationWithMyrmidons>
538
+ When delegating to a Myrmidon:
539
+ - Be specific about the task and expected output format
540
+ - Reference file paths/lines, don't paste full contents
541
+ - Set clear boundaries: what to do and what not to do
542
+ - Tell the Myrmidon to report back concisely (one paragraph, under 500 words max)
543
+ </CommunicationWithMyrmidons>
544
+
545
+ ${DELEGATION_REMINDER}
546
+ `;
547
+ }
548
+ function createTitanAgent(myrmidons, model, customPrompt, customAppendPrompt) {
549
+ const basePrompt = buildTitanPrompt(myrmidons);
550
+ const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
551
+ const definition = {
552
+ name: "titan",
553
+ description: "Primary orchestrator \u2014 plans, delegates, and synthesizes. Delegates everything possible to faster Myrmidons.",
554
+ config: {
555
+ temperature: 0.1,
556
+ prompt
557
+ }
558
+ };
559
+ if (model) {
560
+ definition.config.model = model;
561
+ }
562
+ return definition;
563
+ }
564
+
565
+ // src/agents/index.ts
566
+ function createAgents(config, options) {
567
+ const myrmidons = getMyrmidonConfigs(config);
568
+ const titanModel = config?.titan?.model;
569
+ const titanPrompts = loadAgentPrompt("titan", {
570
+ projectDirectory: options?.projectDirectory
571
+ });
572
+ const titan = createTitanAgent(
573
+ myrmidons,
574
+ typeof titanModel === "string" ? titanModel : void 0,
575
+ config?.titan?.prompt,
576
+ void 0
577
+ );
578
+ titan.config.prompt = resolvePrompt(
579
+ titan.config.prompt ?? "",
580
+ titanPrompts.prompt,
581
+ titanPrompts.appendPrompt
582
+ );
583
+ if (config?.titan) {
584
+ if (config.titan.temperature !== void 0) {
585
+ titan.config.temperature = config.titan.temperature;
586
+ }
587
+ if (config.titan.variant) {
588
+ titan.config.variant = config.titan.variant;
589
+ }
590
+ }
591
+ const myrmidonAgents = myrmidons.map(
592
+ (myrmidonConfig, idx) => createMyrmidonAgent(idx, myrmidonConfig)
593
+ );
594
+ const aliasAgents = myrmidonAgents.map((agent, idx) => ({
595
+ ...agent,
596
+ name: `child-${idx}`,
597
+ description: `[Deprecated alias of @myrmidon-${idx}] ${agent.description ?? ""}`.trim()
598
+ }));
599
+ return [titan, ...myrmidonAgents, ...aliasAgents];
600
+ }
601
+ function getAgentConfigs(config, options) {
602
+ const agents = createAgents(config, options);
603
+ const entries = [];
604
+ for (const agent of agents) {
605
+ const sdkConfig = {
606
+ ...agent.config,
607
+ description: agent.description
608
+ };
609
+ if (agent.name === TITAN_AGENT_NAME) {
610
+ sdkConfig.mode = "primary";
611
+ } else {
612
+ sdkConfig.mode = "subagent";
613
+ }
614
+ if (agent.displayName) {
615
+ sdkConfig.displayName = agent.displayName;
616
+ }
617
+ entries.push([agent.name, sdkConfig]);
618
+ }
619
+ return Object.fromEntries(entries);
620
+ }
621
+
622
+ // src/utils/provider-lock.ts
623
+ var ProviderLockManager = class {
624
+ providers = /* @__PURE__ */ new Map();
625
+ /**
626
+ * Acquire a slot for `model` on `provider`. Resolves with an idempotent
627
+ * release function once a slot is available.
628
+ *
629
+ * @param provider Logical provider (physical backend) identifier.
630
+ * @param model The model to load — same-model acquisitions can share
631
+ * the provider concurrently.
632
+ * @param maxConcurrent Maximum concurrent instances of this model (>= 1).
633
+ */
634
+ acquire(provider, model, maxConcurrent = 1) {
635
+ const state = this.getState(provider);
636
+ return new Promise((resolve) => {
637
+ state.queue.push({
638
+ model,
639
+ maxConcurrent: Math.max(1, Math.floor(maxConcurrent)),
640
+ grant: resolve
641
+ });
642
+ this.pump(provider);
643
+ });
644
+ }
645
+ getState(provider) {
646
+ let state = this.providers.get(provider);
647
+ if (!state) {
648
+ state = { activeModel: null, activeCount: 0, queue: [] };
649
+ this.providers.set(provider, state);
650
+ }
651
+ return state;
652
+ }
653
+ /**
654
+ * Grant as many queued waiters as the current provider state allows. Only the
655
+ * queue head is considered each step to preserve FIFO fairness (a different
656
+ * model cannot be skipped by later same-model waiters).
657
+ */
658
+ pump(provider) {
659
+ const state = this.providers.get(provider);
660
+ if (!state) return;
661
+ while (state.queue.length > 0) {
662
+ const head = state.queue[0];
663
+ const canGrant = state.activeModel === null || state.activeModel === head.model && state.activeCount < head.maxConcurrent;
664
+ if (!canGrant) break;
665
+ state.queue.shift();
666
+ state.activeModel = head.model;
667
+ state.activeCount++;
668
+ head.grant(this.makeRelease(provider, state));
669
+ }
670
+ }
671
+ makeRelease(provider, state) {
672
+ let released = false;
673
+ return () => {
674
+ if (released) return;
675
+ released = true;
676
+ state.activeCount--;
677
+ if (state.activeCount <= 0) {
678
+ state.activeCount = 0;
679
+ state.activeModel = null;
680
+ if (state.queue.length === 0 && this.providers.get(provider) === state) {
681
+ this.providers.delete(provider);
682
+ return;
683
+ }
684
+ }
685
+ this.pump(provider);
686
+ };
687
+ }
688
+ };
689
+
690
+ // src/index.ts
691
+ var PROVIDER_LOCK_TIMEOUT_MS = 30 * 60 * 1e3;
692
+ var OpenCodeDistributedDelegation = async (ctx) => {
693
+ let config;
694
+ let agents;
695
+ let sessionAgentMap;
696
+ let providerLocks;
697
+ let agentLockInfoMap;
698
+ try {
699
+ config = loadPluginConfig(ctx.directory);
700
+ agents = getAgentConfigs(config, { projectDirectory: ctx.directory });
701
+ sessionAgentMap = /* @__PURE__ */ new Map();
702
+ providerLocks = new ProviderLockManager();
703
+ agentLockInfoMap = buildAgentLockInfoMap(getMyrmidonConfigs(config));
704
+ } catch (err) {
705
+ console.error(`[opencode-titan] FATAL: init failed: ${err}`);
706
+ throw err;
707
+ }
708
+ const myrmidonCount = getMyrmidonConfigs(config).length;
709
+ if (myrmidonCount < 1) {
710
+ console.warn(
711
+ "[opencode-titan] WARN: No Myrmidons configured. Titan cannot delegate without Myrmidons. Add myrmidons to your config."
712
+ );
713
+ }
714
+ const heldLocks = /* @__PURE__ */ new Map();
715
+ const lockKey = (sessionID, callID) => `${sessionID}:${callID}`;
716
+ const releaseLock = (key) => {
717
+ const held = heldLocks.get(key);
718
+ if (!held) return;
719
+ heldLocks.delete(key);
720
+ clearTimeout(held.timer);
721
+ held.release();
722
+ };
723
+ return {
724
+ name: "opencode-titan",
725
+ agent: agents,
726
+ // Enforce the provider constraint at runtime. A Myrmidon's provider
727
+ // represents the physical backend serving its model; a backend can only
728
+ // hold ONE model in VRAM at a time. So different models on the same
729
+ // provider are serialized, while up to `maxInstances` instances of the SAME
730
+ // model may run concurrently. Acquiring a per-provider, model-aware slot
731
+ // before the subagent starts and releasing it after enforces this even when
732
+ // Titan dispatches tasks "in parallel".
733
+ "tool.execute.before": async (input, output) => {
734
+ if (input.tool !== "task") return;
735
+ const args = output.args;
736
+ const subagentType = args?.subagent_type;
737
+ if (!subagentType) return;
738
+ const lockInfo = agentLockInfoMap.get(subagentType);
739
+ if (!lockInfo) return;
740
+ const { provider, model, maxInstances } = lockInfo;
741
+ const key = lockKey(input.sessionID, input.callID);
742
+ if (heldLocks.has(key)) return;
743
+ const release = await providerLocks.acquire(
744
+ provider,
745
+ model,
746
+ maxInstances
747
+ );
748
+ const timer = setTimeout(() => {
749
+ if (heldLocks.has(key)) {
750
+ console.warn(
751
+ `[opencode-titan] WARN: provider lock for "${provider}" (${key}) auto-released after timeout; the task may have been interrupted.`
752
+ );
753
+ releaseLock(key);
754
+ }
755
+ }, PROVIDER_LOCK_TIMEOUT_MS);
756
+ timer.unref?.();
757
+ heldLocks.set(key, { provider, release, timer });
758
+ },
759
+ // Release the provider lock once the subagent task completes (or errors).
760
+ "tool.execute.after": async (input) => {
761
+ if (input.tool !== "task") return;
762
+ releaseLock(lockKey(input.sessionID, input.callID));
763
+ },
764
+ config: async (opencodeConfig) => {
765
+ if (!opencodeConfig.default_agent) {
766
+ opencodeConfig.default_agent = TITAN_AGENT_NAME;
767
+ }
768
+ if (!opencodeConfig.agent) {
769
+ opencodeConfig.agent = { ...agents };
770
+ } else {
771
+ for (const [name, pluginAgent] of Object.entries(agents)) {
772
+ const existing = opencodeConfig.agent[name];
773
+ if (existing) {
774
+ opencodeConfig.agent[name] = {
775
+ ...pluginAgent,
776
+ ...existing
777
+ };
778
+ } else {
779
+ opencodeConfig.agent[name] = {
780
+ ...pluginAgent
781
+ };
782
+ }
783
+ }
784
+ }
785
+ },
786
+ // Track which agent each session uses for serve-mode prompt injection,
787
+ // and re-assert the delegation directive on every user turn to Titan.
788
+ "chat.message": async (input, output) => {
789
+ const agent = input.agent ?? output?.message?.agent;
790
+ if (agent) {
791
+ sessionAgentMap.set(input.sessionID, agent);
792
+ }
793
+ if (agent !== TITAN_AGENT_NAME || !output?.parts) {
794
+ return;
795
+ }
796
+ const alreadyInjected = output.parts.some(
797
+ (p) => p && p.type === "text" && typeof p.text === "string" && p.text.includes("<delegation_directive>")
798
+ );
799
+ if (alreadyInjected) {
800
+ return;
801
+ }
802
+ output.parts.push({
803
+ id: `prt_deleg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
804
+ sessionID: input.sessionID,
805
+ messageID: output.message?.id ?? "",
806
+ type: "text",
807
+ text: PER_MESSAGE_DELEGATION_REMINDER,
808
+ synthetic: true
809
+ });
810
+ },
811
+ // Inject delegation reminder for Titan in serve-mode sessions
812
+ "experimental.chat.system.transform": async (input, output) => {
813
+ const agentName = input.sessionID ? sessionAgentMap.get(input.sessionID) : void 0;
814
+ if (agentName === TITAN_AGENT_NAME) {
815
+ const alreadyInjected = output.system.some(
816
+ (s) => typeof s === "string" && s.includes(DELEGATION_REMINDER_SENTINEL)
817
+ );
818
+ if (!alreadyInjected) {
819
+ if (output.system.length > 0) {
820
+ const lastIdx = output.system.length - 1;
821
+ const last = output.system[lastIdx];
822
+ if (typeof last === "string") {
823
+ output.system[lastIdx] = `${last}
824
+
825
+ ${DELEGATION_REMINDER}`;
826
+ }
827
+ }
828
+ }
829
+ }
830
+ },
831
+ // Clean up session tracking on deletion
832
+ event: async (input) => {
833
+ const event = input.event;
834
+ if (event.type === "session.deleted") {
835
+ const props = event.properties;
836
+ const sessionID = props?.info?.id ?? props?.sessionID;
837
+ if (sessionID) {
838
+ sessionAgentMap.delete(sessionID);
839
+ for (const key of [...heldLocks.keys()]) {
840
+ if (key.startsWith(`${sessionID}:`)) {
841
+ releaseLock(key);
842
+ }
843
+ }
844
+ }
845
+ }
846
+ }
847
+ };
848
+ };
849
+ var index_default = OpenCodeDistributedDelegation;
850
+ export {
851
+ index_default as default
852
+ };