@cat-factory/executor-harness 1.64.4 → 1.68.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/job.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseValidationChecksSpec, } from './validation-checks.js';
2
2
  import { parseReproductionSpec, } from './reproduction-proof.js';
3
+ import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
3
4
  function str(value, path) {
4
5
  if (typeof value !== 'string' || value.length === 0) {
5
6
  throw new Error(`Invalid job: '${path}' must be a non-empty string`);
@@ -411,78 +412,6 @@ function parseContextFiles(value) {
411
412
  }
412
413
  return files;
413
414
  }
414
- /**
415
- * Sanitize a skill resource's relative path: keep the subdirectory structure (so
416
- * `templates/report.md` materialises nested) but reject anything that could escape the skill
417
- * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
418
- * for an unsafe path (the resource is then dropped).
419
- */
420
- function sanitizeSkillRelPath(value) {
421
- if (typeof value !== 'string')
422
- return undefined;
423
- const segments = value.replace(/\\/g, '/').split('/');
424
- const clean = [];
425
- for (const seg of segments) {
426
- if (seg === '' || seg === '.')
427
- continue;
428
- if (seg === '..')
429
- return undefined;
430
- // Same character class as a context-file name, per segment.
431
- const c = seg.replace(/[^A-Za-z0-9._-]/g, '');
432
- if (!c || c === '.' || c === '..' || c.startsWith('.'))
433
- return undefined;
434
- clean.push(c);
435
- }
436
- return clean.length ? clean.join('/') : undefined;
437
- }
438
- /**
439
- * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
440
- * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
441
- * default keeps the skill installable rather than dropping it — which, on the claude-code path,
442
- * would leave the prompt pointing at a skill that was never installed (a blind run).
443
- */
444
- const FALLBACK_SKILL_NAME = 'skill';
445
- /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
446
- function sanitizeSkillName(value) {
447
- if (typeof value !== 'string')
448
- return undefined;
449
- const base = value.replace(/\\/g, '/').split('/').pop() ?? '';
450
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '');
451
- if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.'))
452
- return undefined;
453
- return cleaned;
454
- }
455
- /** Validate the optional `skill` field, or undefined when absent/malformed. */
456
- function parseSkillSpec(value) {
457
- if (typeof value !== 'object' || value === null)
458
- return undefined;
459
- const o = value;
460
- const instructions = typeof o.instructions === 'string' ? o.instructions : undefined;
461
- // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
462
- // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
463
- // directory, so fall back to a safe default rather than dropping the whole skill.
464
- if (!instructions)
465
- return undefined;
466
- const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME;
467
- const description = typeof o.description === 'string' ? o.description : '';
468
- const resources = [];
469
- if (Array.isArray(o.resources)) {
470
- const used = new Set();
471
- for (const entry of o.resources) {
472
- if (typeof entry !== 'object' || entry === null)
473
- continue;
474
- const e = entry;
475
- const relPath = sanitizeSkillRelPath(e.relPath);
476
- if (!relPath || used.has(relPath))
477
- continue;
478
- if (typeof e.content !== 'string')
479
- continue;
480
- used.add(relPath);
481
- resources.push({ relPath, content: e.content });
482
- }
483
- }
484
- return { name, description, instructions, resources };
485
- }
486
415
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
487
416
  function parseAgentInfraSpec(value) {
488
417
  if (typeof value !== 'object' || value === null)
@@ -669,7 +598,8 @@ export function parseAgentJob(input) {
669
598
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
670
599
  contextFiles: parseContextFiles(o.contextFiles),
671
600
  packageRegistries: parsePackageRegistries(o.packageRegistries),
672
- skill: parseSkillSpec(o.skill),
601
+ skills: parseSkillSpecs(o.skills),
602
+ mcpServers: parseMcpServerSpecs(o.mcpServers),
673
603
  testSecrets: parseTestSecrets(o.testSecrets),
674
604
  guardLimits: parseGuardLimits(o.guardLimits),
675
605
  validation: parseValidationSpec(o.validation),
@@ -731,7 +661,7 @@ function parseAgentPrSpec(raw) {
731
661
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
732
662
  */
733
663
  function assembleAgentJob(o, mode, agentField, parts) {
734
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
664
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
735
665
  const repo = (o.repo ?? {});
736
666
  return {
737
667
  jobId: str(o.jobId, 'jobId'),
@@ -748,7 +678,8 @@ function assembleAgentJob(o, mode, agentField, parts) {
748
678
  ...(output ? { output } : {}),
749
679
  ...(contextFiles.length ? { contextFiles } : {}),
750
680
  ...(packageRegistries.length ? { packageRegistries } : {}),
751
- ...(skill ? { skill } : {}),
681
+ ...(skills ? { skills } : {}),
682
+ ...(mcpServers ? { mcpServers } : {}),
752
683
  ...(testSecrets.length ? { testSecrets } : {}),
753
684
  ...(infra ? { infra } : {}),
754
685
  ...(pr ? { pr } : {}),
@@ -142,14 +142,14 @@ export async function runAgentInWorkspace(spec, opts = {}) {
142
142
  // harness paths; kept out of the agent's commits via a local git exclude entry.
143
143
  const contextFiles = spec.contextFiles ?? [];
144
144
  await materializeContextFiles(spec.dir, contextFiles);
145
- // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
146
- // so it reads from there. Everything else reads the checkout, so materialise the skill's
147
- // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
148
- // backend) — Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
149
- // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
150
- // `runClaudeCode`). A resource-free skill is a no-op here.
151
- if (spec.skill && !installsSkillNatively(spec)) {
152
- await materializeSkillResources(spec.dir, spec.skill);
145
+ // Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
146
+ // there. Everything else reads the checkout, so materialise each skill's resources under
147
+ // `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
148
+ // Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
149
+ // runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
150
+ // Resource-free skills are a no-op here.
151
+ if (spec.skills?.length && !installsSkillNatively(spec)) {
152
+ await materializeSkillResources(spec.dir, spec.skills);
153
153
  }
154
154
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
155
155
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
@@ -169,7 +169,8 @@ export async function runAgentInWorkspace(spec, opts = {}) {
169
169
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
170
170
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
171
171
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
172
- ...(spec.skill ? { skill: spec.skill } : {}),
172
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
173
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
173
174
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
174
175
  signal: opts.signal,
175
176
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
@@ -240,12 +241,12 @@ export async function runAgentInWorkspace(spec, opts = {}) {
240
241
  return withEffortReport(spec.dir, piOutcome);
241
242
  }
242
243
  /**
243
- * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
244
- * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
244
+ * Whether the claude-code runner will install this run's skills natively (into the CLI's config
245
+ * dir) rather than the caller materialising them into the checkout. True ONLY for a
245
246
  * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
246
- * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
247
- * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
248
- * skills from different repos would overwrite each other's.
247
+ * uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
248
+ * outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
249
+ * would overwrite each other's.
249
250
  */
250
251
  export function installsSkillNatively(spec) {
251
252
  return spec.harness === 'claude-code' && !spec.ambientAuth;
package/dist/pi.js CHANGED
@@ -203,26 +203,34 @@ export async function materializeContextFiles(cwd, files) {
203
203
  // No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
204
204
  }
205
205
  }
206
- /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
206
+ /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
207
207
  export const SKILL_CONTEXT_SUBDIR = 'skill';
208
208
  /**
209
- * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
210
- * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
211
- * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
212
- * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
213
- * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
214
- * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
215
- * exclude entry. A skill with no resource bodies is a no-op.
209
+ * Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
210
+ * — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
211
+ * (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
212
+ * skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
213
+ * which keys off ambient auth as well as the harness).
214
+ *
215
+ * Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
216
+ * the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
217
+ * overwrite each other — silently handing the agent the wrong template. The names were sanitized
218
+ * to a single safe path segment at the job boundary, as were the resource sub-paths (no
219
+ * traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
220
+ * `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
216
221
  */
217
- export async function materializeSkillResources(cwd, skill) {
218
- if (!skill.resources.length)
222
+ export async function materializeSkillResources(cwd, skills) {
223
+ const withResources = skills.filter((s) => s.resources.length);
224
+ if (!withResources.length)
219
225
  return;
220
- const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR);
221
- await mkdir(dir, { recursive: true });
222
- for (const r of skill.resources) {
223
- const dest = join(dir, r.relPath);
224
- await mkdir(dirname(dest), { recursive: true });
225
- await writeFile(dest, r.content, 'utf8');
226
+ for (const skill of withResources) {
227
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name);
228
+ await mkdir(dir, { recursive: true });
229
+ for (const r of skill.resources) {
230
+ const dest = join(dir, r.relPath);
231
+ await mkdir(dirname(dest), { recursive: true });
232
+ await writeFile(dest, r.content, 'utf8');
233
+ }
226
234
  }
227
235
  const gitRoot = await findGitRoot(cwd);
228
236
  if (!gitRoot)
package/dist/subagents.js CHANGED
@@ -137,7 +137,13 @@ export function startSubagentWatcher(root, opts) {
137
137
  return;
138
138
  const message = event.message;
139
139
  const u = claudeCallUsage(message.usage);
140
- if (u.inputTokens === 0 && u.outputTokens === 0)
140
+ // Every input class counts towards "did this turn report usage at all": a turn riding a
141
+ // warm cache legitimately reports 0 fresh input, and skipping it would drop precisely the
142
+ // cache-heavy calls this telemetry exists to weigh.
143
+ if (u.inputTokens === 0 &&
144
+ u.cacheReadTokens === 0 &&
145
+ u.cacheWriteTokens === 0 &&
146
+ u.outputTokens === 0)
141
147
  return;
142
148
  const content = Array.isArray(message.content) ? message.content : [];
143
149
  const { text, reasoning } = claudeAssistantContent(content);
@@ -154,11 +160,16 @@ export function startSubagentWatcher(root, opts) {
154
160
  responseText: redactBody(text, secrets),
155
161
  reasoningText: redactBody(reasoning, secrets),
156
162
  inputTokens: u.inputTokens,
157
- cachedInputTokens: u.cachedInputTokens,
163
+ cacheReadTokens: u.cacheReadTokens,
164
+ cacheWriteTokens: u.cacheWriteTokens,
158
165
  outputTokens: u.outputTokens,
159
166
  finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
160
167
  }, opts.onCallMetric);
161
- usage.inputTokens += u.inputTokens;
168
+ // The run-level `usage` is the COARSE rotation-window weight, which counts every billed
169
+ // input bucket — unlike the per-call metric above, whose `inputTokens` is fresh-only. Sum
170
+ // all three classes back together here or a cache-heavy subagent looks nearly free to the
171
+ // rotation.
172
+ usage.inputTokens += u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens;
162
173
  usage.outputTokens += u.outputTokens;
163
174
  };
164
175
  const NEWLINE = 0x0a;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.64.4",
3
+ "version": "1.68.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.171.0",
30
- "@cat-factory/server": "0.162.1",
31
- "@cat-factory/spend": "0.12.100"
29
+ "@cat-factory/kernel": "0.176.0",
30
+ "@cat-factory/server": "0.166.0",
31
+ "@cat-factory/spend": "0.12.105"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",