@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/README.md +33 -0
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +111 -25
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +9 -139
- package/dist/bootstrap-mode.js +141 -0
- package/dist/claude-call-aggregator.js +6 -3
- package/dist/claude-stream.js +12 -6
- package/dist/coding-agent.js +85 -67
- package/dist/inline.js +29 -1
- package/dist/job.js +6 -75
- package/dist/pi-workspace.js +15 -14
- package/dist/pi.js +24 -16
- package/dist/subagents.js +14 -3
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +155 -44
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +8 -165
- package/src/bootstrap-mode.ts +174 -0
- package/src/claude-call-aggregator.ts +8 -4
- package/src/claude-stream.ts +15 -7
- package/src/coding-agent.ts +102 -71
- package/src/inline.ts +34 -1
- package/src/job.ts +51 -94
- package/src/pi-workspace.ts +30 -20
- package/src/pi.ts +38 -17
- package/src/subagents.ts +17 -3
package/src/job.ts
CHANGED
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
type ReproductionReport,
|
|
13
13
|
type ReproductionSpec,
|
|
14
14
|
} from './reproduction-proof.js'
|
|
15
|
+
import {
|
|
16
|
+
parseMcpServerSpecs,
|
|
17
|
+
parseSkillSpecs,
|
|
18
|
+
type McpServerSpec,
|
|
19
|
+
type SkillResourceSpec,
|
|
20
|
+
type SkillSpec,
|
|
21
|
+
} from './agent-capabilities.js'
|
|
22
|
+
|
|
23
|
+
// Re-exported so the job body stays the one import site for a harness handler describing a job.
|
|
24
|
+
export type { McpServerSpec, SkillResourceSpec, SkillSpec }
|
|
15
25
|
|
|
16
26
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
17
27
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -632,26 +642,6 @@ export interface ContextFileSpec {
|
|
|
632
642
|
content: string
|
|
633
643
|
}
|
|
634
644
|
|
|
635
|
-
/** One materialisable resource file of a skill (repo-sourced Claude Skills). */
|
|
636
|
-
export interface SkillResourceSpec {
|
|
637
|
-
/** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
|
|
638
|
-
relPath: string
|
|
639
|
-
content: string
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* A repo-sourced Claude Skill to make available for a `skill` step. Materialised HARNESS-AWARE:
|
|
644
|
-
* `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
|
|
645
|
-
* natively, or `.cat-context/skill/<relPath>` for the Pi/codex checkout (their prompt carries the
|
|
646
|
-
* instructions). A dedicated top-level body field (like `packageRegistries`), never a context file.
|
|
647
|
-
*/
|
|
648
|
-
export interface SkillSpec {
|
|
649
|
-
name: string
|
|
650
|
-
description: string
|
|
651
|
-
instructions: string
|
|
652
|
-
resources: SkillResourceSpec[]
|
|
653
|
-
}
|
|
654
|
-
|
|
655
645
|
/** How an explore agent's reply is consumed. */
|
|
656
646
|
export interface AgentOutputSpec {
|
|
657
647
|
/** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
|
|
@@ -739,11 +729,20 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
739
729
|
*/
|
|
740
730
|
packageRegistries?: PackageRegistrySpec[]
|
|
741
731
|
/**
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
732
|
+
* The skills to make available for this run (see {@link SkillSpec}) — a `skill` step's picked
|
|
733
|
+
* skill and/or the playbooks the running agent kind declares. Materialised harness-aware before
|
|
734
|
+
* the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/` for claude-code, or
|
|
735
|
+
* `.cat-context/skill/<name>/<relPath>` for Pi/codex. Absent ⇒ no skills installed.
|
|
745
736
|
*/
|
|
746
|
-
|
|
737
|
+
skills?: SkillSpec[]
|
|
738
|
+
/**
|
|
739
|
+
* Tool servers (MCP) to wire into the agent CLI for this run (see {@link McpServerSpec}). The
|
|
740
|
+
* backend has already dropped anything this harness cannot serve, so every entry here is
|
|
741
|
+
* expected to work. SECRET-BEARING (`env`/`headers` carry resolved credentials), so the config
|
|
742
|
+
* files written from it live outside the checkout and are never logged. Absent ⇒ the CLI's
|
|
743
|
+
* built-in tools only.
|
|
744
|
+
*/
|
|
745
|
+
mcpServers?: McpServerSpec[]
|
|
747
746
|
/**
|
|
748
747
|
* Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
|
|
749
748
|
* band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
|
|
@@ -942,6 +941,12 @@ export interface AgentResult {
|
|
|
942
941
|
exitCode: number
|
|
943
942
|
validationOutputTail?: string
|
|
944
943
|
iteration?: number
|
|
944
|
+
/**
|
|
945
|
+
* The work-branch HEAD the command was judged against. The engine compares it across
|
|
946
|
+
* consecutive failing iterations to end a loop that has stopped committing anything,
|
|
947
|
+
* instead of spending the rest of its budget re-learning that. Absent when unreadable.
|
|
948
|
+
*/
|
|
949
|
+
headSha?: string
|
|
945
950
|
}
|
|
946
951
|
/**
|
|
947
952
|
* Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
|
|
@@ -1029,71 +1034,6 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
|
1029
1034
|
return files
|
|
1030
1035
|
}
|
|
1031
1036
|
|
|
1032
|
-
/**
|
|
1033
|
-
* Sanitize a skill resource's relative path: keep the subdirectory structure (so
|
|
1034
|
-
* `templates/report.md` materialises nested) but reject anything that could escape the skill
|
|
1035
|
-
* directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
|
|
1036
|
-
* for an unsafe path (the resource is then dropped).
|
|
1037
|
-
*/
|
|
1038
|
-
function sanitizeSkillRelPath(value: unknown): string | undefined {
|
|
1039
|
-
if (typeof value !== 'string') return undefined
|
|
1040
|
-
const segments = value.replace(/\\/g, '/').split('/')
|
|
1041
|
-
const clean: string[] = []
|
|
1042
|
-
for (const seg of segments) {
|
|
1043
|
-
if (seg === '' || seg === '.') continue
|
|
1044
|
-
if (seg === '..') return undefined
|
|
1045
|
-
// Same character class as a context-file name, per segment.
|
|
1046
|
-
const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1047
|
-
if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
|
|
1048
|
-
clean.push(c)
|
|
1049
|
-
}
|
|
1050
|
-
return clean.length ? clean.join('/') : undefined
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
|
|
1055
|
-
* purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
|
|
1056
|
-
* default keeps the skill installable rather than dropping it — which, on the claude-code path,
|
|
1057
|
-
* would leave the prompt pointing at a skill that was never installed (a blind run).
|
|
1058
|
-
*/
|
|
1059
|
-
const FALLBACK_SKILL_NAME = 'skill'
|
|
1060
|
-
|
|
1061
|
-
/** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
|
|
1062
|
-
function sanitizeSkillName(value: unknown): string | undefined {
|
|
1063
|
-
if (typeof value !== 'string') return undefined
|
|
1064
|
-
const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
1065
|
-
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1066
|
-
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
|
|
1067
|
-
return cleaned
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/** Validate the optional `skill` field, or undefined when absent/malformed. */
|
|
1071
|
-
function parseSkillSpec(value: unknown): SkillSpec | undefined {
|
|
1072
|
-
if (typeof value !== 'object' || value === null) return undefined
|
|
1073
|
-
const o = value as Record<string, unknown>
|
|
1074
|
-
const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
|
|
1075
|
-
// No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
|
|
1076
|
-
// folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
|
|
1077
|
-
// directory, so fall back to a safe default rather than dropping the whole skill.
|
|
1078
|
-
if (!instructions) return undefined
|
|
1079
|
-
const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
|
|
1080
|
-
const description = typeof o.description === 'string' ? o.description : ''
|
|
1081
|
-
const resources: SkillResourceSpec[] = []
|
|
1082
|
-
if (Array.isArray(o.resources)) {
|
|
1083
|
-
const used = new Set<string>()
|
|
1084
|
-
for (const entry of o.resources) {
|
|
1085
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1086
|
-
const e = entry as Record<string, unknown>
|
|
1087
|
-
const relPath = sanitizeSkillRelPath(e.relPath)
|
|
1088
|
-
if (!relPath || used.has(relPath)) continue
|
|
1089
|
-
if (typeof e.content !== 'string') continue
|
|
1090
|
-
used.add(relPath)
|
|
1091
|
-
resources.push({ relPath, content: e.content })
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return { name, description, instructions, resources }
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
1037
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
1098
1038
|
function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
|
|
1099
1039
|
if (typeof value !== 'object' || value === null) return undefined
|
|
@@ -1251,7 +1191,20 @@ export interface InlineResult {
|
|
|
1251
1191
|
text: string
|
|
1252
1192
|
/** `length` when the model hit its output cap (the reviewer rejects a truncated doc). */
|
|
1253
1193
|
finishReason?: 'stop' | 'length'
|
|
1254
|
-
|
|
1194
|
+
/**
|
|
1195
|
+
* The job's token usage with the input side split into its three ORTHOGONAL classes:
|
|
1196
|
+
* `inputTokens` is FRESH input only, so the total input is
|
|
1197
|
+
* `inputTokens + cacheReadTokens + cacheWriteTokens`. Folded from the per-call metrics below,
|
|
1198
|
+
* which is the only channel that knows the split; a CLI that streamed none falls back to the
|
|
1199
|
+
* coarse total with both cache classes 0 — honest, since on that shape nothing is known to
|
|
1200
|
+
* have been cached.
|
|
1201
|
+
*/
|
|
1202
|
+
usage?: {
|
|
1203
|
+
inputTokens: number
|
|
1204
|
+
cacheReadTokens: number
|
|
1205
|
+
cacheWriteTokens: number
|
|
1206
|
+
outputTokens: number
|
|
1207
|
+
}
|
|
1255
1208
|
/** Per-model-call telemetry lifted from the CLI stream (recorded into `llm_call_metrics`). */
|
|
1256
1209
|
callMetrics?: HarnessCallMetric[]
|
|
1257
1210
|
/** A structured failure marks a job-level failure even on a clean HTTP exit (see JobResultBase). */
|
|
@@ -1322,7 +1275,8 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1322
1275
|
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1323
1276
|
contextFiles: parseContextFiles(o.contextFiles),
|
|
1324
1277
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1325
|
-
|
|
1278
|
+
skills: parseSkillSpecs(o.skills),
|
|
1279
|
+
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
1326
1280
|
testSecrets: parseTestSecrets(o.testSecrets),
|
|
1327
1281
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1328
1282
|
validation: parseValidationSpec(o.validation),
|
|
@@ -1361,7 +1315,8 @@ interface ParsedAgentJobParts {
|
|
|
1361
1315
|
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1362
1316
|
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1363
1317
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1364
|
-
|
|
1318
|
+
skills: ReturnType<typeof parseSkillSpecs>
|
|
1319
|
+
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
1365
1320
|
testSecrets: ReturnType<typeof parseTestSecrets>
|
|
1366
1321
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1367
1322
|
validation: ReturnType<typeof parseValidationSpec>
|
|
@@ -1415,7 +1370,8 @@ function assembleAgentJob(
|
|
|
1415
1370
|
bootstrap,
|
|
1416
1371
|
contextFiles,
|
|
1417
1372
|
packageRegistries,
|
|
1418
|
-
|
|
1373
|
+
skills,
|
|
1374
|
+
mcpServers,
|
|
1419
1375
|
testSecrets,
|
|
1420
1376
|
guardLimits,
|
|
1421
1377
|
validation,
|
|
@@ -1439,7 +1395,8 @@ function assembleAgentJob(
|
|
|
1439
1395
|
...(output ? { output } : {}),
|
|
1440
1396
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
1441
1397
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
1442
|
-
...(
|
|
1398
|
+
...(skills ? { skills } : {}),
|
|
1399
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
1443
1400
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
1444
1401
|
...(infra ? { infra } : {}),
|
|
1445
1402
|
...(pr ? { pr } : {}),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import type { RepoSpec
|
|
4
|
+
import type { RepoSpec } from './job.js'
|
|
5
|
+
import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
|
|
5
6
|
import { readEffortReport } from './effort.js'
|
|
6
7
|
import { log } from './logger.js'
|
|
7
8
|
import {
|
|
@@ -206,12 +207,20 @@ export interface AgentRunSpec {
|
|
|
206
207
|
*/
|
|
207
208
|
contextFiles?: ContextFileInfo[]
|
|
208
209
|
/**
|
|
209
|
-
*
|
|
210
|
-
* the
|
|
211
|
-
*
|
|
212
|
-
* folded-in instructions).
|
|
210
|
+
* The skills to make available for this run — a `skill` step's picked skill and/or the playbooks
|
|
211
|
+
* the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
|
|
212
|
+
* natively into the config dir's `skills/`; for Pi/codex the resource files are materialised
|
|
213
|
+
* under `.cat-context/skill/<name>/` (their prompt already carries the folded-in instructions).
|
|
214
|
+
* Absent ⇒ no skills.
|
|
213
215
|
*/
|
|
214
|
-
|
|
216
|
+
skills?: SkillSpec[]
|
|
217
|
+
/**
|
|
218
|
+
* Tool servers (MCP) to wire into the agent CLI. Served by the subscription harnesses only —
|
|
219
|
+
* Pi has no MCP client, and the BACKEND is what decides that (it drops an unservable server and
|
|
220
|
+
* tells the agent so), which is why this path simply forwards whatever it is given rather than
|
|
221
|
+
* re-deciding. Absent ⇒ the CLI's built-in tools only.
|
|
222
|
+
*/
|
|
223
|
+
mcpServers?: McpServerSpec[]
|
|
215
224
|
/**
|
|
216
225
|
* Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
|
|
217
226
|
* backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
|
|
@@ -268,14 +277,14 @@ export async function runAgentInWorkspace(
|
|
|
268
277
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
269
278
|
const contextFiles = spec.contextFiles ?? []
|
|
270
279
|
await materializeContextFiles(spec.dir, contextFiles)
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
if (spec.
|
|
278
|
-
await materializeSkillResources(spec.dir, spec.
|
|
280
|
+
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
281
|
+
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
282
|
+
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
283
|
+
// Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
|
|
284
|
+
// runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
|
|
285
|
+
// Resource-free skills are a no-op here.
|
|
286
|
+
if (spec.skills?.length && !installsSkillNatively(spec)) {
|
|
287
|
+
await materializeSkillResources(spec.dir, spec.skills)
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
@@ -296,7 +305,8 @@ export async function runAgentInWorkspace(
|
|
|
296
305
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
297
306
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
298
307
|
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
299
|
-
...(spec.
|
|
308
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
309
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
300
310
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
301
311
|
signal: opts.signal,
|
|
302
312
|
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
@@ -367,12 +377,12 @@ export async function runAgentInWorkspace(
|
|
|
367
377
|
}
|
|
368
378
|
|
|
369
379
|
/**
|
|
370
|
-
* Whether the claude-code runner will install this run's
|
|
371
|
-
*
|
|
380
|
+
* Whether the claude-code runner will install this run's skills natively (into the CLI's config
|
|
381
|
+
* dir) rather than the caller materialising them into the checkout. True ONLY for a
|
|
372
382
|
* leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
|
|
373
|
-
* uses the developer's own `~/.claude`, which the runner will not write a
|
|
374
|
-
*
|
|
375
|
-
*
|
|
383
|
+
* uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
|
|
384
|
+
* outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
|
|
385
|
+
* would overwrite each other's.
|
|
376
386
|
*/
|
|
377
387
|
export function installsSkillNatively(
|
|
378
388
|
spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,
|
package/src/pi.ts
CHANGED
|
@@ -253,29 +253,37 @@ export async function materializeContextFiles(
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
/** Subdirectory of {@link CONTEXT_DIR} where a
|
|
256
|
+
/** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
|
|
257
257
|
export const SKILL_CONTEXT_SUBDIR = 'skill'
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
* Materialise
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
260
|
+
* Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
|
|
261
|
+
* — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
|
|
262
|
+
* (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
|
|
263
|
+
* skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
|
|
264
|
+
* which keys off ambient auth as well as the harness).
|
|
265
|
+
*
|
|
266
|
+
* Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
|
|
267
|
+
* the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
|
|
268
|
+
* overwrite each other — silently handing the agent the wrong template. The names were sanitized
|
|
269
|
+
* to a single safe path segment at the job boundary, as were the resource sub-paths (no
|
|
270
|
+
* traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
|
|
271
|
+
* `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
|
|
267
272
|
*/
|
|
268
273
|
export async function materializeSkillResources(
|
|
269
274
|
cwd: string,
|
|
270
|
-
|
|
275
|
+
skills: { name: string; resources: { relPath: string; content: string }[] }[],
|
|
271
276
|
): Promise<void> {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
const withResources = skills.filter((s) => s.resources.length)
|
|
278
|
+
if (!withResources.length) return
|
|
279
|
+
for (const skill of withResources) {
|
|
280
|
+
const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name)
|
|
281
|
+
await mkdir(dir, { recursive: true })
|
|
282
|
+
for (const r of skill.resources) {
|
|
283
|
+
const dest = join(dir, r.relPath)
|
|
284
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
285
|
+
await writeFile(dest, r.content, 'utf8')
|
|
286
|
+
}
|
|
279
287
|
}
|
|
280
288
|
const gitRoot = await findGitRoot(cwd)
|
|
281
289
|
if (!gitRoot) return
|
|
@@ -509,8 +517,21 @@ export interface HarnessCallMetric {
|
|
|
509
517
|
responseText: string
|
|
510
518
|
/** The reasoning/thinking trace, as a plain string (`''` when none). */
|
|
511
519
|
reasoningText: string
|
|
520
|
+
/**
|
|
521
|
+
* FRESH (uncached) input tokens: exclusive of BOTH cache classes below, so the three
|
|
522
|
+
* are orthogonal and additive. Every producer normalises to this — reading the already
|
|
523
|
+
* exclusive field where the vendor reports the classes apart (Anthropic), subtracting
|
|
524
|
+
* the cached share where the vendor reports an inclusive prompt count (Codex/OpenAI).
|
|
525
|
+
*/
|
|
512
526
|
inputTokens: number
|
|
513
|
-
|
|
527
|
+
/** Input tokens served from the vendor's prompt cache (~0.1× base input). */
|
|
528
|
+
cacheReadTokens: number
|
|
529
|
+
/**
|
|
530
|
+
* Input tokens written INTO the vendor's cache (1.25–2× base input — dearer than fresh),
|
|
531
|
+
* kept apart from the reads so a loop that keeps re-writing the prefix is distinguishable
|
|
532
|
+
* from one riding a warm cache. 0 where the CLI reports no separate write class.
|
|
533
|
+
*/
|
|
534
|
+
cacheWriteTokens: number
|
|
514
535
|
outputTokens: number
|
|
515
536
|
/** The provider finish/stop reason when the CLI reports one (else null). */
|
|
516
537
|
finishReason: string | null
|
package/src/subagents.ts
CHANGED
|
@@ -236,7 +236,16 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
|
|
|
236
236
|
if (event.type !== 'assistant' || !isObject(event.message)) return
|
|
237
237
|
const message = event.message as Record<string, unknown>
|
|
238
238
|
const u = claudeCallUsage(message.usage)
|
|
239
|
-
|
|
239
|
+
// Every input class counts towards "did this turn report usage at all": a turn riding a
|
|
240
|
+
// warm cache legitimately reports 0 fresh input, and skipping it would drop precisely the
|
|
241
|
+
// cache-heavy calls this telemetry exists to weigh.
|
|
242
|
+
if (
|
|
243
|
+
u.inputTokens === 0 &&
|
|
244
|
+
u.cacheReadTokens === 0 &&
|
|
245
|
+
u.cacheWriteTokens === 0 &&
|
|
246
|
+
u.outputTokens === 0
|
|
247
|
+
)
|
|
248
|
+
return
|
|
240
249
|
const content = Array.isArray(message.content) ? message.content : []
|
|
241
250
|
const { text, reasoning } = claudeAssistantContent(content)
|
|
242
251
|
publishCallMetric(
|
|
@@ -254,13 +263,18 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
|
|
|
254
263
|
responseText: redactBody(text, secrets),
|
|
255
264
|
reasoningText: redactBody(reasoning, secrets),
|
|
256
265
|
inputTokens: u.inputTokens,
|
|
257
|
-
|
|
266
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
267
|
+
cacheWriteTokens: u.cacheWriteTokens,
|
|
258
268
|
outputTokens: u.outputTokens,
|
|
259
269
|
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
260
270
|
},
|
|
261
271
|
opts.onCallMetric,
|
|
262
272
|
)
|
|
263
|
-
usage
|
|
273
|
+
// The run-level `usage` is the COARSE rotation-window weight, which counts every billed
|
|
274
|
+
// input bucket — unlike the per-call metric above, whose `inputTokens` is fresh-only. Sum
|
|
275
|
+
// all three classes back together here or a cache-heavy subagent looks nearly free to the
|
|
276
|
+
// rotation.
|
|
277
|
+
usage.inputTokens += u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens
|
|
264
278
|
usage.outputTokens += u.outputTokens
|
|
265
279
|
}
|
|
266
280
|
|