@ninemind/agentgem 0.2.0 → 0.3.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.
@@ -0,0 +1,55 @@
1
+ // src/gem/sandboxLaunch.ts
2
+ // Pure generators for the OS-native sandbox launchers. The v1 boundary contains
3
+ // FILESYSTEM WRITES to the run dir (+ temp); reads, exec, and network stay open. This
4
+ // "write-deny" shape (allow-all, then deny writes, then re-allow under runDir) avoids
5
+ // the deny-default trap that kills the agent's own runtime before it can start.
6
+ import { tmpdir } from "node:os";
7
+ import { realpathSync } from "node:fs";
8
+ // Resolve symlinks when the path exists; fall back to the original string otherwise.
9
+ function tryRealpath(p) {
10
+ try {
11
+ return realpathSync(p);
12
+ }
13
+ catch {
14
+ return p;
15
+ }
16
+ }
17
+ export function seatbeltPolicy(runDir, tmpDir = tmpdir()) {
18
+ // Resolve symlinks so the SBPL subpath clause matches the kernel's canonical path.
19
+ // On macOS, tmpdir() returns /var/folders/... but the kernel sees /private/var/folders/...
20
+ // Fall back to the original path if the directory doesn't exist yet.
21
+ const realRun = tryRealpath(runDir);
22
+ const realTmp = tryRealpath(tmpDir);
23
+ return [
24
+ "(version 1)",
25
+ "(allow default)",
26
+ "(deny file-write*)",
27
+ "(allow file-write*",
28
+ ` (subpath ${q(realRun)})`,
29
+ ` (subpath ${q(realTmp)})`,
30
+ ' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr")',
31
+ ' (subpath "/dev/tty") (regex #"^/dev/fd/"))',
32
+ ].join("\n");
33
+ }
34
+ // SBPL string literal: wrap in double quotes (paths under our control have no quotes).
35
+ function q(p) { return `"${p}"`; }
36
+ export function bwrapArgs(runDir, tmpDir = tmpdir()) {
37
+ // Resolve symlinks so the writable bind matches the kernel's canonical path
38
+ // (mirrors seatbeltPolicy); fall back to the original if the dir doesn't exist yet.
39
+ const realRun = tryRealpath(runDir);
40
+ const realTmp = tryRealpath(tmpDir);
41
+ return [
42
+ "--ro-bind", "/", "/", // everything readable, nothing writable…
43
+ "--bind", realRun, realRun, // …except the run dir…
44
+ "--bind", realTmp, realTmp, // …and temp.
45
+ "--dev", "/dev",
46
+ "--unshare-pid", // own PID namespace: the agent can't see/signal host processes
47
+ "--proc", "/proc", // fresh procfs for that namespace (must follow --unshare-pid)
48
+ "--die-with-parent",
49
+ ];
50
+ }
51
+ export function wrapWithSandbox(kind, runDir, command) {
52
+ if (kind === "macos-seatbelt")
53
+ return ["sandbox-exec", "-p", seatbeltPolicy(runDir), ...command];
54
+ return ["bwrap", ...bwrapArgs(runDir), "--", ...command];
55
+ }
@@ -0,0 +1,108 @@
1
+ // src/gem/scrub.ts
2
+ //
3
+ // Field-aware, default-deny scrubber for builtin tool_use inputs captured during
4
+ // transcript distillation (see docs/proposals/skill-distillation-from-transcripts.md
5
+ // §3a). Unlike redact.ts (which redacts secret VALUES in a structured config),
6
+ // this keeps only an allowlisted structural slice per builtin and DROPS everything
7
+ // else — removing the file-content/PII class by construction rather than blocklist.
8
+ //
9
+ // Output per step: { verb, arg } — a coarse low-cardinality `verb` for procedure
10
+ // recurrence (§3c) and a minimal scrubbed `arg` for the agent.
11
+ // Token-level secret detection. redact.ts redacts the WHOLE value and can lean on
12
+ // bare keywords because it has key/value structure; here we scrub free-text command
13
+ // tokens, where bare words like "secret"/"token"/"password" legitimately appear in
14
+ // filenames and commit messages (`cat secret.env`). So the rule is intentionally
15
+ // narrower: a token is secret only if it is HIGH-ENTROPY or carries a known secret
16
+ // PREFIX — never a plain dictionary keyword. (Short keyword-less secrets survive;
17
+ // that residual risk is accepted under the draft-only review gate — proposal §3a.)
18
+ const SECRET_PREFIX_RE = /^(sk-|ghp_|gho_|ghu_|ghs_|github_pat_|xox[a-z]-|AKIA|ASIA|glpat-)/;
19
+ function looksLikeSecretToken(t) {
20
+ if (t.length >= 32 && /^[A-Za-z0-9_-]+$/.test(t))
21
+ return true; // high entropy
22
+ if (t.length >= 8 && SECRET_PREFIX_RE.test(t))
23
+ return true; // prefixed token
24
+ return false;
25
+ }
26
+ // Rewrite $HOME-absolute prefixes to ~ and any /Users/<name>/ to ~/, so paths
27
+ // never carry a username. Applied before token scrub.
28
+ function dehomePaths(s) {
29
+ const home = process.env.HOME;
30
+ let out = home ? s.split(home).join("~") : s;
31
+ out = out.replace(/\/Users\/[^/\s]+\//g, "~/");
32
+ return out;
33
+ }
34
+ // Token-scrub a free-text arg: de-home paths, then replace any secret-looking
35
+ // whitespace token with <redacted>, leaving the rest of the command intact.
36
+ function scrubText(s) {
37
+ return dehomePaths(s)
38
+ .split(/(\s+)/) // keep separators so spacing is preserved
39
+ .map((tok) => (/\s/.test(tok) ? tok : redactToken(tok)))
40
+ .join("");
41
+ }
42
+ // A token may carry a secret embedded in surrounding syntax (https://SECRET@host).
43
+ // Redact the embedded secret, not the whole token, so structure survives.
44
+ function redactToken(tok) {
45
+ if (!tok)
46
+ return tok;
47
+ return tok
48
+ .split(/([^A-Za-z0-9_-]+)/) // split on non-identifier runs, keep them
49
+ .map((part) => (/^[A-Za-z0-9_-]+$/.test(part) && looksLikeSecretToken(part) ? "<redacted>" : part))
50
+ .join("");
51
+ }
52
+ // Scrub free-text prose (mission-hint task/outcome). Same token scrub + de-home as
53
+ // command args, plus a hard length cap — this is the one place free text is kept,
54
+ // so it is deliberately short and low-detail (proposal §3b).
55
+ export function scrubProse(s, maxLen = 280) {
56
+ const scrubbed = scrubText(s).trim();
57
+ if (scrubbed.length <= maxLen)
58
+ return scrubbed;
59
+ return scrubbed.slice(0, maxLen) + "…";
60
+ }
61
+ // Coarse procedure verb: "git commit -m fix" -> "Bash:git commit", "cd /x" ->
62
+ // "Bash:cd", "/usr/bin/npx vitest" -> "Bash:npx vitest". argv0 is basenamed; the
63
+ // 2nd token counts as a subcommand ONLY if it's a clean lowercase word — a path,
64
+ // filename, flag, or quoted arg is NOT a subcommand, so it never inflates the verb.
65
+ function bashVerb(command) {
66
+ const toks = command.trim().split(/\s+/).filter(Boolean);
67
+ if (!toks.length)
68
+ return "Bash";
69
+ const argv0 = (toks[0].split("/").pop() || toks[0]).replace(/[;|&]+$/, "");
70
+ if (!argv0)
71
+ return "Bash";
72
+ const sub = toks[1] && /^[a-z][a-z0-9-]*$/.test(toks[1]) ? ` ${toks[1]}` : "";
73
+ return `Bash:${argv0}${sub}`;
74
+ }
75
+ function str(input, key) {
76
+ const v = input?.[key];
77
+ return typeof v === "string" ? v : "";
78
+ }
79
+ // Default-deny: each builtin keeps only an allowlisted structural slice; every
80
+ // other field (file contents, agent prompts, tool output, unknown fields) is
81
+ // dropped, not scrubbed — so the file-content/PII class is removed by construction.
82
+ export function scrubStep(tool, input) {
83
+ switch (tool) {
84
+ case "Bash": {
85
+ const command = str(input, "command");
86
+ return { verb: bashVerb(command), arg: scrubText(command) };
87
+ }
88
+ // Edit/Write/NotebookEdit: keep the path; DROP old_string/new_string/content.
89
+ case "Edit":
90
+ case "Write":
91
+ case "NotebookEdit":
92
+ return { verb: tool, arg: scrubText(str(input, "file_path") || str(input, "notebook_path")) };
93
+ // Read/Grep/Glob: keep the path/pattern; DROP file contents and match output.
94
+ case "Read":
95
+ case "Grep":
96
+ case "Glob":
97
+ return { verb: tool, arg: scrubText(str(input, "file_path") || str(input, "path") || str(input, "pattern")) };
98
+ // Task/agent spawns: keep the short description; DROP the prompt.
99
+ case "Task":
100
+ case "Agent": {
101
+ const sub = str(input, "subagent_type");
102
+ return { verb: sub ? `${tool}:${sub}` : tool, arg: scrubText(str(input, "description")) };
103
+ }
104
+ // Unknown tool: verb only, entire input dropped.
105
+ default:
106
+ return { verb: tool, arg: "" };
107
+ }
108
+ }
@@ -0,0 +1,34 @@
1
+ // Weighted field match: name >> tags > description. An empty query (with optional
2
+ // kind/tag filter) browses the catalog — every gem returns, score 0.
3
+ export function searchIndex(index, query, opts = {}) {
4
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
5
+ const hits = [];
6
+ for (const [key, item] of Object.entries(index.items)) {
7
+ const d = item.discovery ?? {};
8
+ if (opts.kind && !(d.artifactKinds ?? []).includes(opts.kind))
9
+ continue;
10
+ if (opts.tag && !(d.tags ?? []).includes(opts.tag))
11
+ continue;
12
+ const name = key.toLowerCase();
13
+ const tags = (d.tags ?? []).join(" ").toLowerCase();
14
+ const desc = (d.description ?? "").toLowerCase();
15
+ let score = 0;
16
+ for (const t of terms) {
17
+ if (name === t)
18
+ score += 100;
19
+ else if (name.includes(t))
20
+ score += 10;
21
+ if (tags.split(/\s+/).includes(t))
22
+ score += 5;
23
+ else if (tags.includes(t))
24
+ score += 3;
25
+ if (desc.includes(t))
26
+ score += 1;
27
+ }
28
+ if (terms.length && score === 0)
29
+ continue; // query given but nothing matched
30
+ hits.push({ key, latest: item.latest, score, description: d.description, tags: d.tags, author: d.author, artifactKinds: d.artifactKinds, updatedAt: d.updatedAt });
31
+ }
32
+ hits.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key));
33
+ return hits.slice(0, opts.limit ?? 25);
34
+ }
@@ -0,0 +1,21 @@
1
+ // src/gem/share.ts
2
+ // The registry-optional "easy share" loop: turn a Gem into one portable .gem file
3
+ // and install one back. Pure + in-process — no disk/network — so it composes with
4
+ // any transport (file, URL, gist, paste). Integrity is inherited from readGemArchive,
5
+ // which verifies gem.lock and throws on any mismatch, so a tampered .gem never installs.
6
+ import { writeGemArchive, readGemArchive, readGemMeta } from "./archive.js";
7
+ import { packTar, unpackTar } from "./archiveTar.js";
8
+ import { safePathSegment } from "./targets.js";
9
+ // Gem -> a single self-verifying .gem (gzipped tar of the archive file tree).
10
+ export function exportGem(gem, opts = {}) {
11
+ const { files, skipped } = writeGemArchive(gem, opts);
12
+ const version = opts.version ?? "0.1.0";
13
+ return { filename: `${safePathSegment(gem.name)}-${version}.gem`, bytes: packTar(files), skipped };
14
+ }
15
+ // A .gem's bytes -> the verified Gem. Throws if the bytes aren't a valid archive
16
+ // or if gem.lock verification fails (tampering / corruption).
17
+ export function importGem(bytes) {
18
+ const files = unpackTar(bytes);
19
+ const gem = readGemArchive(files); // verifies gem.lock; throws on mismatch
20
+ return { gem, meta: readGemMeta(files) };
21
+ }
@@ -1,3 +1,4 @@
1
+ import { channelScaffold } from "./channels.js";
1
2
  import { tomlMcpServers } from "./toml.js";
2
3
  import { stdioProxyRunner, PROXY_BASE_PORT, PROXY_HOST } from "./mcpProxy.js";
3
4
  export function safePathSegment(name) {
@@ -19,6 +20,17 @@ const fluePascal = (name) => flueName(name).split("-").filter(Boolean).map((w) =
19
20
  // Flue skills live under src/ (the agent file is src/agents/<name>.ts and imports ../skills/...).
20
21
  const skillFlueMd = (a) => ({ [`src/skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
21
22
  const rendered = (files) => ({ files, skipped: [] });
23
+ // MCP header secrets -> [headerName, envVarName] entries, Authorization first. Shared by the HTTP/SSE
24
+ // renderers (flue / openai-sandbox / a2a); the env-var NAME is emitted, never a value. Callers that
25
+ // require header-only auth check separately for a non-`headers.` secret (the unsupported case).
26
+ const headerSecretEntries = (refs) => {
27
+ const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
28
+ return [
29
+ ...(authorization ? [["Authorization", authorization.name]] : []),
30
+ ...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
31
+ .map((r) => [r.location.slice("headers.".length), r.name]),
32
+ ];
33
+ };
22
34
  // ── shared convention renderers ──
23
35
  const skillSkillMd = (a) => ({ [`skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
24
36
  const skillDescriptionMd = (a) => ({ [`skills/${safePathSegment(a.name)}/DESCRIPTION.md`]: a.content });
@@ -220,13 +232,7 @@ function escapeTemplate(s) {
220
232
  // env var name, never a value). stdio -> a localhost connection plus a generated proxy runner under
221
233
  // proxies/ that bridges the stdio server to HTTP (same mechanism as Eve).
222
234
  const flueConnection = (server, url) => {
223
- const refs = server.secretRefs ?? [];
224
- const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
225
- const headerEntries = [
226
- ...(authorization ? [["Authorization", authorization.name]] : []),
227
- ...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
228
- .map((r) => [r.location.slice("headers.".length), r.name]),
229
- ];
235
+ const headerEntries = headerSecretEntries(server.secretRefs ?? []);
230
236
  const transport = server.transport === "sse" ? `,\n transport: "sse"` : "";
231
237
  const headers = headerEntries.length
232
238
  ? `,\n headers: { ${headerEntries.map(([h, env]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(env)}]!`).join(", ")} }`
@@ -339,11 +345,7 @@ const sandboxMcpServer = (s) => {
339
345
  const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
340
346
  if (unsupported)
341
347
  return { skip: `OpenAI sandbox cannot map secret at ${unsupported.location}` };
342
- const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
343
- const headerEntries = [
344
- ...(authorization ? [["Authorization", authorization.name]] : []),
345
- ...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization).map((r) => [r.location.slice("headers.".length), r.name]),
346
- ];
348
+ const headerEntries = headerSecretEntries(refs);
347
349
  const requestInit = headerEntries.length
348
350
  ? `, requestInit: { headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} } }`
349
351
  : "";
@@ -475,10 +477,10 @@ const evePackageJson = (gemName) => JSON.stringify({
475
477
  type: "module",
476
478
  imports: { "#*": "./agent/*", "#evals/*": "./evals/*" },
477
479
  scripts: { build: "eve build", dev: "eve dev", start: "eve start", typecheck: "tsgo" },
478
- dependencies: { "@vercel/connect": "0.2.2", ai: "7.0.0-beta.178", eve: "^0.11.7", microsandbox: "^0.5.0", zod: "4.4.3" },
480
+ dependencies: { "@vercel/connect": "0.2.2", ai: "7.0.2", eve: "^0.15.0", microsandbox: "^0.5.0", zod: "4.4.3" },
479
481
  devDependencies: { "@types/node": "24.x", "@typescript/native-preview": "7.0.0-dev.20260523.1" },
480
- overrides: { ai: "7.0.0-beta.178" },
481
- resolutions: { ai: "7.0.0-beta.178" },
482
+ overrides: { ai: "7.0.2" },
483
+ resolutions: { ai: "7.0.2" },
482
484
  engines: { node: "24.x" },
483
485
  }, null, 2) + "\n";
484
486
  // Cross-cutting scaffold: the files `eve init` provides so the rendered agent/ source is runnable.
@@ -499,6 +501,26 @@ const eveComposeProject = (gem, opts = {}) => {
499
501
  }
500
502
  return rendered(files);
501
503
  };
504
+ // Eve channel files: one agent/channels/<name>.ts per declared channel, from the platform registry
505
+ // scaffold. "eve" is reserved for the always-on web/auth channel that eveComposeProject emits.
506
+ const channelEve = (channels) => {
507
+ const files = {};
508
+ const skipped = [];
509
+ for (const c of channels) {
510
+ const seg = eveSegment(c.name);
511
+ const path = `agent/channels/${seg}.ts`;
512
+ if (seg === "eve") {
513
+ skipped.push({ artifact: c.name, type: "channel", reason: "channel name 'eve' is reserved for the web channel" });
514
+ continue;
515
+ }
516
+ if (path in files) {
517
+ skipped.push({ artifact: c.name, type: "channel", reason: `path collision with an earlier channel at ${path}` });
518
+ continue;
519
+ }
520
+ files[path] = channelScaffold(c.platform);
521
+ }
522
+ return { files, skipped };
523
+ };
502
524
  // ── A2A (Agent2Agent) target ──
503
525
  // Card primitive: materialize(gem, "a2a") emits a runtime-free Agent Card derived from the gem — the
504
526
  // A2A discovery surface, publishable to the registry. The Card is the part native to AgentGem's
@@ -512,10 +534,13 @@ const a2aSkillCard = (a) => ({
512
534
  });
513
535
  // A one-line card description from an instruction artifact: prefer the first non-empty *prose* line
514
536
  // (instruction files usually open with a throwaway "# Title" heading); fall back to the de-headed
515
- // first line if the doc is headings-only.
537
+ // first line if the doc is headings-only. Only ATX headings ("# " … "###### ") count as headings, so a
538
+ // prose line that merely starts with '#' (e.g. "#launch") is kept. Bounded so the card carries a label,
539
+ // not a paragraph.
516
540
  const a2aFirstLine = (s) => {
517
541
  const lines = s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
518
- return lines.find((l) => !l.startsWith("#")) ?? lines[0]?.replace(/^#+\s*/, "") ?? "";
542
+ const line = lines.find((l) => !/^#{1,6}\s/.test(l)) ?? lines[0]?.replace(/^#+\s*/, "") ?? "";
543
+ return line.length > 200 ? line.slice(0, 197).replace(/\s+\S*$/, "") + "…" : line;
519
544
  };
520
545
  // Pure Gem -> AgentCard projection. Skills advertise as A2A skills (metadata, not bodies); the first
521
546
  // instruction line becomes the card description; a skill-less Gem gets a synthesized `chat` skill
@@ -529,7 +554,9 @@ export const a2aAgentCard = (gem) => {
529
554
  name: gem.name,
530
555
  description: a2aFirstLine(instr[0]?.content ?? "") || `An agent packaged by AgentGem from ${skills.length} skill(s).`,
531
556
  version: "0.1.0",
532
- url: "http://localhost:41241/a2a/jsonrpc", // discovery placeholder; the (future) server overrides from PUBLIC_URL
557
+ // Non-resolving placeholder (RFC 6761 reserved TLD): a published card must NOT carry a localhost url
558
+ // a consumer would dial against its own machine. Server mode rebinds this from PUBLIC_URL at boot.
559
+ url: "https://set-public-url.invalid/a2a/jsonrpc",
533
560
  capabilities: { streaming: false, pushNotifications: false },
534
561
  defaultInputModes: ["text"],
535
562
  defaultOutputModes: ["text"],
@@ -548,12 +575,7 @@ const a2aMcpClient = (s) => {
548
575
  const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
549
576
  if (unsupported)
550
577
  return { skip: `A2A (AI SDK) cannot map secret at ${unsupported.location}` };
551
- const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
552
- const headerEntries = [
553
- ...(authorization ? [["Authorization", authorization.name]] : []),
554
- ...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
555
- .map((r) => [r.location.slice("headers.".length), r.name]),
556
- ];
578
+ const headerEntries = headerSecretEntries(refs);
557
579
  const headers = headerEntries.length
558
580
  ? `, headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} }`
559
581
  : "";
@@ -584,8 +606,8 @@ const a2aSecretsMd = (secrets) => {
584
606
  const a2aPackageJson = (gemName) => JSON.stringify({
585
607
  name: safePathSegment(gemName).toLowerCase(), version: "0.1.0", private: true, type: "module",
586
608
  scripts: { build: "tsc", start: "node dist/server.js", dev: "tsx src/server.ts" },
587
- // Verified pins: ai v7 beta pairs with @ai-sdk/mcp v2 beta; @a2a-js/sdk 0.3.x.
588
- dependencies: { "@a2a-js/sdk": "^0.3.13", ai: "7.0.0-beta.178", "@ai-sdk/mcp": "2.0.0-beta.67", express: "^5", uuid: "^11" },
609
+ // Verified pins: ai v7 GA pairs with @ai-sdk/mcp v2 GA (both on @ai-sdk/provider@4); @a2a-js/sdk 0.3.x.
610
+ dependencies: { "@a2a-js/sdk": "^0.3.13", ai: "7.0.2", "@ai-sdk/mcp": "2.0.0", express: "^5", uuid: "^11" },
589
611
  devDependencies: { "@types/express": "^5", "@types/node": "^24", tsx: "^4", typescript: "^5" },
590
612
  }, null, 2) + "\n";
591
613
  // The runnable A2A server: an AI SDK `streamText` tool loop behind the @a2a-js/sdk JSON-RPC handler.
@@ -632,7 +654,15 @@ class GemExecutor implements AgentExecutor {
632
654
  private inflight = new Map<string, AbortController>();
633
655
  async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
634
656
  const { taskId, contextId, userMessage, task } = ctx;
635
- const text = (userMessage.parts ?? []).filter((p: any) => p.kind === "text").map((p: any) => p.text).join("\\n");
657
+ const text = (userMessage.parts ?? []).filter((p: any) => p.kind === "text").map((p: any) => p.text).join("\\n").trim();
658
+ // Guard: an A2A message may carry no text parts (file/data only). streamText rejects an empty
659
+ // prompt, so reply directly instead of failing the request.
660
+ if (!text) {
661
+ bus.publish({ kind: "message", messageId: uuid(), role: "agent", contextId,
662
+ parts: [{ kind: "text", text: "Please include a text message for the agent." }] });
663
+ bus.finished();
664
+ return;
665
+ }
636
666
  const ac = new AbortController();
637
667
  this.inflight.set(taskId, ac);
638
668
  if (!task) bus.publish({ kind: "task", id: taskId, contextId, status: { state: "submitted", timestamp: new Date().toISOString() }, history: [userMessage] });
@@ -646,7 +676,8 @@ class GemExecutor implements AgentExecutor {
646
676
  artifact: { artifactId, name: "response", parts: [{ kind: "text", text: delta }] } });
647
677
  started = true;
648
678
  }
649
- bus.publish({ kind: "artifact-update", taskId, contextId, append: true, lastChunk: true, artifact: { artifactId, parts: [] } });
679
+ // Only close an artifact that was actually opened (empty/tool-only completions stream nothing).
680
+ if (started) bus.publish({ kind: "artifact-update", taskId, contextId, append: true, lastChunk: true, artifact: { artifactId, parts: [] } });
650
681
  bus.publish({ kind: "status-update", taskId, contextId, status: { state: "completed", timestamp: new Date().toISOString() }, final: true });
651
682
  } catch (err) {
652
683
  const state = ac.signal.aborted ? "canceled" : "failed";
@@ -675,22 +706,29 @@ app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAu
675
706
  app.listen(port, () => console.log(\`A2A agent "\${card.name}" listening on :\${port}\`));
676
707
  `;
677
708
  };
678
- // A2A is wholly compose-driven: per-type renderers are no-ops (so no artifact is skip-reported), and
679
- // compose emits the Agent Card. Card-only mode models neither MCP nor hooks, so nothing is skipped.
680
- // With opts.a2aServer, it additionally emits a runnable server and evaluates MCP/hook mappability.
709
+ // A2A is wholly compose-driven: per-type renderers are no-ops (so materialize never auto-skip-reports),
710
+ // and compose owns ALL skip reporting for both modes. Hooks are never expressible by A2A (card or
711
+ // server). MCP is not expressible by a *Card* (card-only -> all MCP skipped), but the *server* wires it
712
+ // (server mode -> only unmappable MCP skipped). This keeps compatibility() honest: card-only reflects
713
+ // that a Card carries identity + skills, not MCP/hooks, instead of over-claiming full support.
681
714
  const a2aComposeProject = (gem, opts = {}) => {
682
715
  const files = { "agent-card.json": JSON.stringify(a2aAgentCard(gem), null, 2) + "\n" };
683
- if (!opts.a2aServer)
684
- return { files, skipped: [] };
685
- const skills = gem.artifacts.filter((a) => a.type === "skill");
686
- const instr = gem.artifacts.filter((a) => a.type === "instructions");
687
716
  const mcps = gem.artifacts.filter((a) => a.type === "mcp_server");
688
717
  const hooks = gem.artifacts.filter((a) => a.type === "hook");
718
+ const hookSkips = hooks.map((h) => ({ artifact: h.name, type: "hook", reason: "A2A has no hook concept" }));
719
+ if (!opts.a2aServer) {
720
+ // Card-only: an Agent Card represents identity + skills, not MCP servers or hooks.
721
+ const cardSkips = mcps.map((s) => ({ artifact: s.name, type: "mcp_server", reason: "an Agent Card cannot express MCP servers (materialize with a2aServer to wire them)" }));
722
+ return { files, skipped: [...cardSkips, ...hookSkips] };
723
+ }
724
+ const skills = gem.artifacts.filter((a) => a.type === "skill");
725
+ const instr = gem.artifacts.filter((a) => a.type === "instructions");
689
726
  // AI SDK has no skills primitive -> fold skill bodies (frontmatter-stripped) into the system prompt.
690
727
  const instrText = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
691
728
  const skillText = skills.map((s) => `## Skill: ${s.name}\n\n${stripYamlFrontmatter(s.content)}`).join("\n\n---\n\n");
692
729
  const system = [instrText, skillText].filter(Boolean).join("\n\n---\n\n");
693
- const skipped = [];
730
+ // Server mode: the server wires MCP, so only UNMAPPABLE MCP is skipped; hooks remain unsupported.
731
+ const skipped = [...hookSkips];
694
732
  const clientCodes = [];
695
733
  let usesStdio = false;
696
734
  for (const s of mcps) {
@@ -702,8 +740,6 @@ const a2aComposeProject = (gem, opts = {}) => {
702
740
  clientCodes.push(r.code);
703
741
  usesStdio ||= r.stdio;
704
742
  }
705
- for (const h of hooks)
706
- skipped.push({ artifact: h.name, type: "hook", reason: "A2A has no hook concept" });
707
743
  return {
708
744
  files: {
709
745
  ...files,
@@ -721,7 +757,7 @@ export const TARGET_REGISTRY = {
721
757
  agents: { id: "agents", label: "Agents", skill: skillSkillMd, instructions: instructionsAgentsMd },
722
758
  hermes: { id: "hermes", label: "Hermes", skill: skillDescriptionMd, instructions: instructionsSoulMd },
723
759
  // Eve project layout (agent/...). Hooks are event-reacting code in Eve, not config -> unsupported.
724
- eve: { id: "eve", label: "Eve", skill: skillEveMd, instructions: concatInstructions("agent/instructions.md"), mcp: mcpEveConnections, compose: eveComposeProject },
760
+ eve: { id: "eve", label: "Eve", skill: skillEveMd, instructions: concatInstructions("agent/instructions.md"), mcp: mcpEveConnections, channel: channelEve, compose: eveComposeProject },
725
761
  // Flue project layout. Skills reuse SKILL.md; instructions fold into the composed agent file (no
726
762
  // standalone file -> the empty instructions renderer marks them handled, not skipped). MCP added in Task 2.
727
763
  flue: { id: "flue", label: "Flue", skill: skillFlueMd, instructions: () => ({}), mcp: mcpFlueConnections, compose: flueComposeAgent },
@@ -753,6 +789,7 @@ export function materialize(gem, target, opts = {}) {
753
789
  const mcp = gem.artifacts.filter((a) => a.type === "mcp_server");
754
790
  const instr = gem.artifacts.filter((a) => a.type === "instructions");
755
791
  const hooks = gem.artifacts.filter((a) => a.type === "hook");
792
+ const channels = gem.artifacts.filter((a) => a.type === "channel");
756
793
  if (spec.skill)
757
794
  for (const s of skills)
758
795
  merge(spec.skill(s), s.name, "skill");
@@ -779,6 +816,15 @@ export function materialize(gem, target, opts = {}) {
779
816
  else
780
817
  skipAll(hooks, "hook");
781
818
  }
819
+ if (channels.length) {
820
+ if (spec.channel) {
821
+ const result = spec.channel(channels);
822
+ merge(result.files, channels.map((c) => c.name).join(", "), "channel");
823
+ skipped.push(...result.skipped);
824
+ }
825
+ else
826
+ skipAll(channels, "channel");
827
+ }
782
828
  if (spec.compose) {
783
829
  const result = spec.compose(gem, opts);
784
830
  merge(result.files, "(composed agent)", "instructions"); // collisions reported; agent file derives from instructions+skills
Binary file
@@ -8,6 +8,7 @@ import { mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync } fr
8
8
  import { materialize, compatibility, TARGET_REGISTRY, safePathSegment } from "./targets.js";
9
9
  import { writeGemArchive, readGemArchive } from "./archive.js";
10
10
  import { writeArchiveDir, readArchiveDir } from "./archiveFs.js";
11
+ import { InvalidInputError } from "./inputError.js";
11
12
  const TARGETS_DIR = ".targets";
12
13
  export function workspacesRoot() {
13
14
  const home = process.env.AGENTGEM_HOME ?? join(homedir(), ".agentgem");
@@ -18,7 +19,7 @@ export function workspacesRoot() {
18
19
  export function workspaceName(name) {
19
20
  const seg = safePathSegment(name);
20
21
  if (seg !== name)
21
- throw new Error(`invalid workspace name '${name}' — use only [A-Za-z0-9._-], no separators`);
22
+ throw new InvalidInputError(`invalid workspace name '${name}' — use only [A-Za-z0-9._-], no separators`);
22
23
  return seg;
23
24
  }
24
25
  export function workspaceDir(name) {
@@ -73,12 +74,12 @@ export function readWorkspace(name) {
73
74
  const gem = readGemArchive(files); // verifies the lock
74
75
  return { ...summary(workspaceName(name), files["gem.json"], dir), files, compatibility: compatibility(gem) };
75
76
  }
76
- export function renderTarget(name, target) {
77
+ export function renderTarget(name, target, opts = {}) {
77
78
  const dir = workspaceDir(name);
78
79
  if (!existsSync(join(dir, "gem.json")))
79
80
  throw new Error(`no workspace '${name}'`);
80
81
  const gem = readGemArchive(readArchiveDir(dir));
81
- const { files, skipped } = materialize(gem, target);
82
+ const { files, skipped } = materialize(gem, target, opts);
82
83
  const out = join(dir, TARGETS_DIR, target);
83
84
  rmSync(out, { recursive: true, force: true }); // clear stale renders
84
85
  mkdirSync(out, { recursive: true });