opencode-agenthub 0.1.1 → 0.1.3

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.
@@ -1,11 +1,17 @@
1
1
  import { readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { normalizeModelSelection, pickModelSelection } from "./model-utils.js";
4
+ import { spawn } from "node:child_process";
5
+ import {
6
+ normalizeModelSelection,
7
+ pickModelSelection,
8
+ validateModelAgainstCatalog,
9
+ validateModelIdentifier
10
+ } from "./model-utils.js";
5
11
  import { buildBuiltinVersionManifest } from "./builtin-assets.js";
6
12
  import { getDefaultProfilePlugins } from "./defaults.js";
7
13
  import { readPackageVersion } from "./package-version.js";
8
- import { resolveHomeConfigRoot } from "./platform.js";
14
+ import { resolveHomeConfigRoot, spawnOptions } from "./platform.js";
9
15
  const hrPrimaryAgentName = "hr";
10
16
  const recommendedHrBootstrapModel = "openai/gpt-5.4-mini";
11
17
  const recommendedHrBootstrapVariant = "high";
@@ -253,6 +259,97 @@ const normalizeConfiguredModel = (value) => {
253
259
  return trimmed.length > 0 ? trimmed : void 0;
254
260
  };
255
261
  const fallbackInstalledModel = (installedModels, nativeModel) => installedModels[hrPrimaryAgentName]?.model || installedModels.auto?.model || Object.values(installedModels)[0]?.model || nativeModel;
262
+ const readHrKnownModelIds = async (targetRoot) => {
263
+ const filePath = path.join(targetRoot, "inventory", "models", "valid-model-ids.txt");
264
+ try {
265
+ const raw = await readFile(filePath, "utf8");
266
+ const values = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
267
+ return values.length > 0 ? new Set(values) : void 0;
268
+ } catch {
269
+ return void 0;
270
+ }
271
+ };
272
+ const listAvailableOpencodeModels = async () => new Promise((resolve) => {
273
+ const child = spawn("opencode", ["models"], {
274
+ stdio: ["ignore", "pipe", "ignore"],
275
+ ...spawnOptions()
276
+ });
277
+ const timeout = setTimeout(() => {
278
+ child.kill();
279
+ resolve(void 0);
280
+ }, 15e3);
281
+ let stdout = "";
282
+ child.stdout?.on("data", (chunk) => {
283
+ stdout += chunk.toString();
284
+ });
285
+ child.on("error", () => {
286
+ clearTimeout(timeout);
287
+ resolve(void 0);
288
+ });
289
+ child.on("close", () => {
290
+ clearTimeout(timeout);
291
+ const models = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
292
+ resolve(models.length > 0 ? [...new Set(models)].sort() : void 0);
293
+ });
294
+ });
295
+ const probeOpencodeModelAvailability = async (model, options = {}) => {
296
+ const models = await (options.listModels ?? listAvailableOpencodeModels)();
297
+ if (!models) {
298
+ return {
299
+ available: false,
300
+ reason: "probe_failed",
301
+ message: "Unable to verify model availability from opencode. Continue only if you know this model works in your environment."
302
+ };
303
+ }
304
+ return models.includes(model) ? { available: true } : {
305
+ available: false,
306
+ reason: "unavailable",
307
+ message: `Model '${model}' is not available in the current opencode environment.`
308
+ };
309
+ };
310
+ const agentModelValidationMessage = (agentName, model, status) => `Agent '${agentName}' model '${model}' is invalid: ${status.message}`;
311
+ const validateHrAgentModelConfiguration = async (targetRoot, settings, options = {}) => {
312
+ const currentSettings = settings ?? await readAgentHubSettings(targetRoot);
313
+ if (!currentSettings?.agents) return { valid: true };
314
+ const knownModels = await readHrKnownModelIds(targetRoot);
315
+ const availabilityCache = /* @__PURE__ */ new Map();
316
+ for (const agentName of hrAgentNames) {
317
+ const model = currentSettings.agents[agentName]?.model;
318
+ if (typeof model !== "string" || model.trim().length === 0) continue;
319
+ const syntax = validateModelIdentifier(model);
320
+ if (!syntax.ok) {
321
+ return {
322
+ valid: false,
323
+ agentName,
324
+ model,
325
+ syntax,
326
+ message: agentModelValidationMessage(agentName, model, syntax)
327
+ };
328
+ }
329
+ const catalog = validateModelAgainstCatalog(model, knownModels);
330
+ if (!catalog.ok) {
331
+ return {
332
+ valid: false,
333
+ agentName,
334
+ model,
335
+ catalog,
336
+ message: agentModelValidationMessage(agentName, model, catalog)
337
+ };
338
+ }
339
+ const availability = availabilityCache.get(model) || await probeOpencodeModelAvailability(model, options);
340
+ availabilityCache.set(model, availability);
341
+ if (!availability.available && availability.reason !== "probe_failed") {
342
+ return {
343
+ valid: false,
344
+ agentName,
345
+ model,
346
+ availability,
347
+ message: agentModelValidationMessage(agentName, model, availability)
348
+ };
349
+ }
350
+ }
351
+ return { valid: true };
352
+ };
256
353
  const resolveHrBootstrapAgentModels = async ({
257
354
  targetRoot,
258
355
  selection
@@ -383,11 +480,14 @@ export {
383
480
  hrAgentNames,
384
481
  hrPrimaryAgentName,
385
482
  hrSubagentNames,
483
+ listAvailableOpencodeModels,
386
484
  loadNativeOpenCodeConfig,
387
485
  loadNativeOpenCodePreferences,
388
486
  mergeAgentHubSettingsDefaults,
389
487
  nativeOpenCodeConfigPath,
488
+ probeOpencodeModelAvailability,
390
489
  readAgentHubSettings,
490
+ readHrKnownModelIds,
391
491
  readNativeAgentOverrides,
392
492
  readNativePluginEntries,
393
493
  readWorkflowInjectionConfig,
@@ -395,6 +495,7 @@ export {
395
495
  recommendedHrBootstrapVariant,
396
496
  resolveHrBootstrapAgentModels,
397
497
  settingsPathForRoot,
498
+ validateHrAgentModelConfiguration,
398
499
  workflowInjectionPathForRoot,
399
500
  writeAgentHubSettings
400
501
  };
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile, access } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { readAgentHubSettings } from "../../composer/settings.js";
4
+ import { validateModelIdentifier } from "../../composer/model-utils.js";
4
5
  const readJson = async (filePath) => {
5
6
  const content = await readFile(filePath, "utf-8");
6
7
  return JSON.parse(content);
@@ -98,8 +99,28 @@ async function runDiagnostics(targetRoot) {
98
99
  if (omoIssue) {
99
100
  report.issues.push(omoIssue);
100
101
  }
102
+ for (const issue of diagnoseInvalidModelSyntax(settings)) {
103
+ report.issues.push(issue);
104
+ }
101
105
  return report;
102
106
  }
107
+ const diagnoseInvalidModelSyntax = (settings) => {
108
+ if (!settings?.agents) return [];
109
+ const issues = [];
110
+ for (const [agentName, agent] of Object.entries(settings.agents)) {
111
+ if (typeof agent.model !== "string" || agent.model.trim().length === 0) continue;
112
+ const syntax = validateModelIdentifier(agent.model);
113
+ if (!syntax.ok) {
114
+ issues.push({
115
+ type: "model_invalid_syntax",
116
+ severity: "error",
117
+ message: `Agent '${agentName}' has an invalid model override: ${syntax.message}`,
118
+ details: { agentName, model: agent.model }
119
+ });
120
+ }
121
+ }
122
+ return issues;
123
+ };
103
124
  async function diagnoseMissingGuards(settings) {
104
125
  if (!settings) return REQUIRED_GUARDS;
105
126
  const existingGuards = Object.keys(settings.guards || {});
@@ -3,9 +3,15 @@ import { readdir, readFile, access, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import {
5
5
  loadNativeOpenCodeConfig,
6
+ probeOpencodeModelAvailability,
7
+ readHrKnownModelIds,
6
8
  readAgentHubSettings,
7
9
  writeAgentHubSettings
8
10
  } from "../../composer/settings.js";
11
+ import {
12
+ validateModelAgainstCatalog,
13
+ validateModelIdentifier
14
+ } from "../../composer/model-utils.js";
9
15
  import {
10
16
  fixMissingGuards,
11
17
  createBundleForSoul,
@@ -686,6 +692,19 @@ async function updateAgentModelOverride(targetRoot, agentName, model) {
686
692
  await writeAgentHubSettings(targetRoot, settings);
687
693
  return `Cleared model override for '${agentName}'.`;
688
694
  }
695
+ const syntax = validateModelIdentifier(trimmed);
696
+ if (!syntax.ok) {
697
+ return `${syntax.message} Use provider/model format or leave blank to clear the override.`;
698
+ }
699
+ const knownModels = await readHrKnownModelIds(targetRoot);
700
+ const catalog = validateModelAgainstCatalog(trimmed, knownModels);
701
+ if (!catalog.ok) {
702
+ return `${catalog.message} Sync HR sources or choose a listed model, then try again.`;
703
+ }
704
+ const availability = await probeOpencodeModelAvailability(trimmed);
705
+ if (!availability.available) {
706
+ return `${availability.message} Pick another model or clear the override.`;
707
+ }
689
708
  settings.agents[agentName] = {
690
709
  ...existing,
691
710
  model: trimmed
@@ -41,7 +41,10 @@ $HR_HOME/staging/<package-id>/
41
41
  - Prefer namespaced bundle/soul/profile names for adapted teams or rewrites so the package does not overwrite shared starter assets like `plan`, `build`, or `explore` unless the human explicitly requests replacement.
42
42
  - If a staged profile sets `defaultAgent`, it must use the staged bundle's `agent.name` value, not the bundle filename. This matters when bundle filenames are namespaced but `agent.name` is shorter.
43
43
  - Before final assembly, confirm whether the operator wants to keep default opencode agents such as `general`, `explore`, `plan`, and `build`. If not, the staged profile must set `"nativeAgentPolicy": "team-only"`. This suppresses host native agent merges and emits `disable: true` overrides for default opencode agents that are not supplied by the staged team itself.
44
- - Before final assembly, confirm whether the promoted profile should become the default personal profile for future bare `agenthub start` runs. Record that choice in `handoff.json` under `promotion_preferences.set_default_profile`.
44
+ - If `nativeAgentPolicy` is `team-only` and the staged bundle set does not already provide `explore`, automatically include the built-in hidden `explore` subagent so the team retains investigation coverage without another user prompt.
45
+ - Before final assembly, MUST verify the staged bundle set includes at least one `agent.mode: "primary"` agent that is not hidden. If all sourced candidates are subagent-style, either add/create a primary host agent or keep native agents visible. Never stage an all-subagent team as `team-only`.
46
+ - If a prior or external flow has set `promotion_preferences.set_default_profile`, preserve it in `handoff.json`. Do not proactively ask the operator about default-profile preferences during assembly.
47
+ - If AI models are still unresolved when final assembly begins, stop and confirm the exact model choice here before writing staged agent defaults. Model confirmation must use opencode environment availability probing, not the synced inventory catalog.
45
48
  - If the operator specifies a model variant such as `xhigh`, `high`, or `thinking`, store it canonically as `agent.model: "provider/model"` plus `agent.variant: "..."`. For backward compatibility, combined strings like `"provider/model xhigh"` may still be accepted on read, but staged output should prefer the split form.
46
49
  - The package must be promotable by:
47
50
 
@@ -26,7 +26,7 @@ Verify that a staged package is understandable and safe before the human operato
26
26
  7. deployment role correctness
27
27
  8. unresolved risks / open decisions
28
28
  9. import-root and assemble-only validation
29
- 10. runtime/default-profile confirmation
29
+ 10. runtime configuration confirmation
30
30
  11. protocol-compliance checkpoints
31
31
 
32
32
  ## Clarity Definitions
@@ -69,13 +69,14 @@ Keep `final-checklist.md` compact and explicit. Use this shape:
69
69
  | descriptions are operator-readable | pass/fail |
70
70
  | MCP registrations resolve to staged servers or blocker | pass/fail |
71
71
  | handoff clearly separates test/use/promote | pass/fail |
72
- | model preferences were explicitly collected | pass/fail |
72
+ | model preferences were confirmed before assembly | pass/fail |
73
73
  | final names were user-confirmed | pass/fail |
74
74
  | specialized work was delegated | pass/fail |
75
- | staged model ids validated against synced catalog | pass/fail |
75
+ | staged model ids confirmed via opencode environment | pass/fail |
76
76
  | profile defaultAgent matches bundle agent.name | pass/fail |
77
+ | team includes at least one primary, non-hidden agent | pass/fail |
77
78
  | default opencode agent policy confirmed | pass/fail |
78
- | default profile on promote confirmed | pass/fail |
79
+ | default-profile preference recorded if present | pass/fail |
79
80
  | no host project mutations | pass/fail |
80
81
  overall: READY FOR HUMAN CONFIRMATION | READY WITH CAVEATS | NOT READY
81
82
  blocker: <description or none>
@@ -89,10 +90,11 @@ The package cannot be marked ready unless the verifier confirms:
89
90
  2. the manual import fallback points to `<package-root>/agenthub-home` and is described as advanced/manual only
90
91
  3. all referenced skills either exist inside the staged `skills/` directory or are explicitly rejected as missing blockers
91
92
  4. `python3 $HR_HOME/bin/validate_staged_package.py $HR_HOME/staging/<package-id>` passes
92
- 5. the package explicitly records whether default opencode agents are kept or hidden, and whether the promoted profile should become the default profile
93
- 6. staged model ids either match the synced catalog exactly or are called out as blockers/caveats for human review
93
+ 5. the package explicitly records whether default opencode agents are kept or hidden, and if `promotion_preferences.set_default_profile` is present, it is consistent
94
+ 6. staged model ids are either confirmed available in the opencode environment or are called out as blockers/caveats for human review
94
95
  7. if any bundle references MCP tools, the staged package includes the referenced `mcp/*.json` files, the required `mcp-servers/` implementation files, and `mcp-servers/package.json` when runtime dependencies are needed
95
96
  8. the handoff clearly shows how to test/use the staged profile in a workspace before promote, and promote is not described as mandatory for workspace use
96
97
  9. if a profile sets `defaultAgent`, that value exactly matches one of the staged bundles' `agent.name` values (not just the bundle filename)
98
+ 10. the staged team includes at least one non-hidden primary agent, and any `team-only` profile keeps at least one such primary agent available to the operator
97
99
 
98
100
  If bundle metadata contains fake runtime keys such as `optional_skills` or `runtime_conditional_skills`, mark the package `NOT READY` until they are removed or rewritten as plain documentation outside runtime bundle semantics.
@@ -37,10 +37,9 @@ Every staffing plan must include:
37
37
  - `composition`
38
38
  - `required_skills`
39
39
  - `required_tools`
40
- - `suggested_model_provider`
41
- - `proposed_agent_models` (initial proposal only; final per-agent defaults require user confirmation later)
42
40
  - `draft_names` (draft agent names and draft profile name for user review)
43
41
  - `risks`
42
+ - `team_size_advisory` (included when recommended team exceeds four agents)
44
43
  - `next_action`
45
44
 
46
45
  ## Compact Summary Shape
@@ -57,10 +56,11 @@ composition:
57
56
  draft_names:
58
57
  - seat: ... | proposed_agent_name: ... | reason: ...
59
58
  - profile: ... | reason: ...
60
- proposed_agent_models:
61
- - agent: ... | model: ...
59
+ required_skills:
60
+ - skill: ... | why: ...
62
61
  risks:
63
62
  - ...
63
+ team_size_advisory: <omit if ≤4 agents; otherwise: "Recommend one to two primary agents with remaining agents as subagents.">
64
64
  next_action: ...
65
65
  ```
66
66
 
@@ -75,11 +75,14 @@ For each recommended entry, specify:
75
75
 
76
76
  ## Decision Rules
77
77
 
78
+ - At least one staffing-plan entry must have `deployment_role: primary-capable`.
78
79
  - Prefer the smallest team that can cover planning, sourcing/exploration, implementation, audit, verification, and documentation.
80
+ - If the recommended team has more than four agents, include a `team_size_advisory` noting that the team should be structured around one to two primary agents with the remaining agents deployed as subagents. Do not recommend more than two primary-capable agents unless the user explicitly requests it.
81
+ - When multiple candidates can cover the same seat, prefer a pure-soul agent with attached skills over a mixed soul+skill agent. Use a mixed soul+skill agent only when the specialized workflow cannot be cleanly separated, the source is tightly fused, or the user explicitly wants that mixed form.
79
82
  - Prefer local worker cards from `$HR_HOME/inventory/workers/` with `inventory_status = available`.
80
83
  - Treat `draft` worker cards as sourcing inputs that still need review or explicit operator acceptance.
81
84
  - Exclude `retired` worker cards from recommended staffing compositions.
82
- - Treat user-supplied model names as advisory until checked against `$HR_HOME/inventory/models/catalog.json` or `$HR_HOME/inventory/models/valid-model-ids.txt`. If an exact catalog match is missing, mark the proposal unresolved instead of inventing a model id.
85
+ - Treat user-supplied model names as advisory. Model confirmation happens during staging via opencode environment availability probing, not during staffing planning. If a user-supplied name looks syntactically invalid, note that, but do not attempt catalog-based validation here.
83
86
  - If multiple valid compositions exist, present them as options rather than pretending one is certain.
84
87
  - If a skill host is unresolved, say so plainly.
85
88
  - Draft names must be treated as proposals only. The parent HR agent must show them to the user and get confirmation before adaptation starts.
@@ -168,6 +168,7 @@ def validate_profile_default_agents(import_root: Path) -> None:
168
168
  )
169
169
 
170
170
  references: list[tuple[str, str]] = []
171
+ primary_agent_names: set[str] = set()
171
172
  missing_bundles: list[str] = []
172
173
  for raw_bundle_name in bundle_names:
173
174
  if not isinstance(raw_bundle_name, str) or not raw_bundle_name.strip():
@@ -181,11 +182,16 @@ def validate_profile_default_agents(import_root: Path) -> None:
181
182
  continue
182
183
  agent = bundle.get("agent", {})
183
184
  agent_name = agent.get("name") if isinstance(agent, dict) else None
185
+ agent_mode = agent.get("mode") if isinstance(agent, dict) else None
186
+ agent_hidden = agent.get("hidden") if isinstance(agent, dict) else None
184
187
  if not isinstance(agent_name, str) or not agent_name.strip():
185
188
  raise SystemExit(
186
189
  f"Bundle '{bundle_name}' is missing required agent.name; profile '{profile_name}' cannot use it."
187
190
  )
188
- references.append((bundle_name, agent_name.strip()))
191
+ normalized_agent_name = agent_name.strip()
192
+ references.append((bundle_name, normalized_agent_name))
193
+ if agent_mode == "primary" and agent_hidden is not True:
194
+ primary_agent_names.add(normalized_agent_name)
189
195
 
190
196
  if missing_bundles:
191
197
  detail = ", ".join(missing_bundles)
@@ -199,6 +205,10 @@ def validate_profile_default_agents(import_root: Path) -> None:
199
205
  raise SystemExit(
200
206
  f"Profile '{profile_name}' uses nativeAgentPolicy 'team-only' and must set defaultAgent explicitly."
201
207
  )
208
+ if native_agent_policy == "team-only" and not primary_agent_names:
209
+ raise SystemExit(
210
+ f"Profile '{profile_name}' uses nativeAgentPolicy 'team-only' but does not include at least one primary, non-hidden agent."
211
+ )
202
212
  if default_agent is None:
203
213
  continue
204
214
  if not isinstance(default_agent, str) or not default_agent.strip():
@@ -224,6 +234,10 @@ def validate_profile_default_agents(import_root: Path) -> None:
224
234
 
225
235
  agent_names = {agent_name for _, agent_name in references}
226
236
  if normalized_default_agent in agent_names:
237
+ if normalized_default_agent not in primary_agent_names:
238
+ raise SystemExit(
239
+ f"Profile '{profile_name}' defaultAgent '{normalized_default_agent}' must point to a primary, non-hidden agent."
240
+ )
227
241
  continue
228
242
 
229
243
  available = ", ".join(sorted(agent_names)) or "(none)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-agenthub",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A control plane for organizing, composing, and activating OpenCode agents, skills, profiles, and bundles.",
5
5
  "type": "module",
6
6
  "main": "./dist/plugins/opencode-agenthub.js",