@bluewombat/slots 0.4.0 → 0.6.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 CHANGED
@@ -92,8 +92,12 @@ or `--report`. A role slot after `--` is what names one:
92
92
  - ./.mason/transcripts
93
93
  ```
94
94
 
95
- `cursor.mjs` needs `cursor-agent` on PATH (`cursor-agent login`); `claude.mjs`
96
- needs `claude`, signed in once.
95
+ `cursor.mjs` needs `cursor-agent` on PATH. Without `--agent-home` it uses the
96
+ CLI's own login (`cursor-agent login`). With `--agent-home DIR` and
97
+ `--api-key-env NAME` the vendor process gets that directory as `HOME` and
98
+ `CURSOR_API_KEY` from `NAME`; the value never appears on the argv. `claude.mjs`
99
+ needs `claude`, signed in once, and refuses those two flags: Claude is not
100
+ isolated yet, and starting it would keep the operator's own config.
97
101
 
98
102
  | Option | Meaning |
99
103
  | ---------------------------------- | -------------------------------------------------------------------------------- |
@@ -106,25 +110,34 @@ needs `claude`, signed in once.
106
110
  | `--transcript-dir PATH` | Write one file per turn under here. Off unless given |
107
111
  | `--transcript-part NAME` | `prompt`, `stdout`, `stderr`, `timing`. Repeatable. Default: all |
108
112
  | `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
113
+ | `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
114
+ | `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
109
115
  | `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
110
116
 
111
117
  The agent CLI prints **one JSON line** — a `SerializedRun` from
112
- `@bluewombat/slot-kit` — with the vendor's raw streams plus two extras the
118
+ `@bluewombat/slot-kit` — with the vendor's raw streams plus three extras the
113
119
  wrapper fills when it can:
114
120
 
115
121
  | Field | Meaning |
116
122
  | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
117
123
  | `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
118
124
  | `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
125
+ | `tools` | `null` = unknown (not stream-json, or unparseable). Otherwise what the agent did with its tools: `calls` per tool name, `busyMs` (time with a tool running; `null` when the stream has no times, as Claude's), and the lists `web` (pages fetched, searches, URLs a shell command named), `mcp` (`server/tool`, plus `getMcpTools` when the agent only listed a server's tools), `subagents` (by description), `outside` (paths named outside the working directory, skill files excepted) |
119
126
 
120
127
  Cursor extracts skills from `stream-json` `readToolCall` paths ending in
121
128
  `/skills/<name>/SKILL.md`. Claude extracts them from `tool_use` blocks named
122
129
  `Skill` (`input.skill`), and adds `--verbose` whenever the format is
123
- `stream-json`. A future vendor agent (`codex.mjs`, …) uses the same fields:
124
- leave them `null` when it cannot tell.
125
-
126
- When `--transcript-dir` is set, the transcript header repeats `skills` and
127
- `usage` (`unknown`, `(none)`, or the values).
130
+ `stream-json`. Tools come from Cursor's `<name>ToolCall` events (`webFetch`,
131
+ `mcp`, `task`, `shell`, … — names seen on cursor-agent 2026.10.01) and from
132
+ Claude's `tool_use` blocks (`WebFetch`, `WebSearch`, `Bash`, `Task`,
133
+ `mcp__<server>__<tool>`); the vendor-free part is `agents/tool-facts.mjs`. A
134
+ future vendor agent (`codex.mjs`, …) uses the same fields: leave them `null`
135
+ when it cannot tell.
136
+
137
+ When `--transcript-dir` is set, the transcript header repeats `skills`,
138
+ `usage` and the tools — `tools`, `web`, `mcp`, `subagents`, `outside the
139
+ workspace` — each `unknown`, `(none)`, or the values. "Did it read the page I
140
+ linked?" is answered there, without reading the stream.
128
141
 
129
142
  A global npm install belongs to one Node version: after `nvm use`, a `claude`
130
143
  installed under another version is off PATH. Give `--bin` the absolute path, or
@@ -250,7 +263,8 @@ loop that misbehaves cannot be read back, only guessed at.
250
263
 
251
264
  `--transcript-dir` writes one Markdown file per turn: the invocation, the prompt
252
265
  as sent, stdout, stderr, how long it took, and — when the agent wrapper passed
253
- them — `skills` and `usage` (`unknown`, `(none)`, or the values). Files are
266
+ them — `skills`, `usage` and what the tools did (`unknown`, `(none)`, or the
267
+ values). Files are
254
268
  filed under the Feature the Task belongs to, named for the Subtask and the
255
269
  Attempt. It belongs on the **agent** command, after `--`.
256
270
 
@@ -9,24 +9,96 @@
9
9
  * Usage: terminal `result` documents `usage` (`input_tokens`, …) and
10
10
  * `total_cost_usd`.
11
11
  *
12
- * `skills` is `null` when stdout is not NDJSON we can parse; `[]` when parsed
13
- * with no Skill tool_use.
12
+ * Tools: every `tool_use` block of an assistant message, counted under its
13
+ * `name`, as Claude Code's tools reference names them: `WebFetch` (`input.url`),
14
+ * `WebSearch` (`input.query`), `Bash` (`input.command`), `Task` / `Agent`
15
+ * (`input.description`), an MCP tool as `mcp__<server>__<tool>`, and
16
+ * `file_path` / `path` on the file tools. The stream carries no times:
17
+ * `busyMs` stays null.
18
+ *
19
+ * `skills` and `tools` are `null` when stdout is not NDJSON we can parse;
20
+ * `skills` is `[]` when parsed with no Skill tool_use.
14
21
  */
15
22
 
16
23
  import { readResult } from "@bluewombat/slot-kit";
24
+ import { count, emptyTools, isOutside, urlsIn } from "./tool-facts.mjs";
17
25
 
18
26
  /**
19
27
  * @param {string} stdout
20
28
  * @param {string} outputFormat
21
- * @returns {{ skills: string[] | null, usage: import("@bluewombat/slot-kit").AgentUsage | null }}
29
+ * @param {string} cwd the directory the agent worked in
30
+ * @returns {import("@bluewombat/slot-kit").AgentExtras}
22
31
  */
23
- export function extrasFromClaude(stdout, outputFormat) {
32
+ export function extrasFromClaude(stdout, outputFormat, cwd) {
33
+ const streamed = outputFormat === "stream-json";
24
34
  return {
25
- skills: outputFormat === "stream-json" ? skillsFromClaude(stdout) : null,
35
+ skills: streamed ? skillsFromClaude(stdout) : null,
26
36
  usage: usageFromClaude(stdout),
37
+ tools: streamed ? toolsFromClaude(stdout, cwd) : null,
27
38
  };
28
39
  }
29
40
 
41
+ /**
42
+ * @param {string} stdout
43
+ * @param {string} cwd the directory the agent worked in
44
+ * @returns {import("@bluewombat/slot-kit").AgentTools | null}
45
+ */
46
+ export function toolsFromClaude(stdout, cwd) {
47
+ const tools = emptyTools();
48
+ /** @type {Set<string>} */
49
+ const seen = new Set();
50
+ let parsedAny = false;
51
+ for (const line of stdout.split("\n")) {
52
+ let event;
53
+ try {
54
+ event = JSON.parse(line);
55
+ } catch {
56
+ continue;
57
+ }
58
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
59
+ continue;
60
+ }
61
+ parsedAny = true;
62
+ const content = event.type === "assistant" ? event.message?.content : undefined;
63
+ if (!Array.isArray(content)) {
64
+ continue;
65
+ }
66
+ for (const block of content) {
67
+ if (block?.type !== "tool_use" || typeof block.name !== "string") {
68
+ continue;
69
+ }
70
+ // A partial-message stream repeats a block; its id says it once.
71
+ if (typeof block.id === "string") {
72
+ if (seen.has(block.id)) {
73
+ continue;
74
+ }
75
+ seen.add(block.id);
76
+ }
77
+ const name = block.name;
78
+ const input = block.input ?? {};
79
+ count(tools, name);
80
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
81
+ if (mcp !== null) {
82
+ tools.mcp.push(`${mcp[1]}/${mcp[2]}`);
83
+ } else if (name === "WebFetch" && typeof input.url === "string") {
84
+ tools.web.push(input.url);
85
+ } else if (name === "WebSearch" && typeof input.query === "string") {
86
+ tools.web.push(`search: ${input.query}`);
87
+ } else if (name === "Bash") {
88
+ tools.web.push(...urlsIn(input.command).map((url) => `${url} (shell)`));
89
+ } else if ((name === "Task" || name === "Agent") && typeof input.description === "string") {
90
+ tools.subagents.push(input.description);
91
+ }
92
+ for (const path of [input.file_path, input.path, input.notebook_path]) {
93
+ if (isOutside(path, cwd)) {
94
+ tools.outside.push(path);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return parsedAny ? tools : null;
100
+ }
101
+
30
102
  /**
31
103
  * @param {string} stdout
32
104
  * @returns {string[] | null}
package/agents/claude.mjs CHANGED
@@ -26,6 +26,8 @@ import { extrasFromClaude } from "./claude-extras.mjs";
26
26
  // --transcript-dir PATH write one file per turn. Off by default
27
27
  // --transcript-part NAME prompt, stdout, stderr, timing. Repeatable
28
28
  // --agent-arg VALUE appended before the prompt. Repeatable
29
+ // --agent-home / --api-key-env recognised, then refused. Claude is not
30
+ // isolated yet; starting would keep the operator's config
29
31
  // --id / --attempt / --context how a transcript is filed
30
32
 
31
33
  const NAME = "Claude Code";
@@ -47,6 +49,15 @@ if (!parsed.ok) {
47
49
  }
48
50
  const options = parsed.values;
49
51
 
52
+ if (
53
+ textOption(options, "agentHome") !== undefined ||
54
+ textOption(options, "apiKeyEnv") !== undefined
55
+ ) {
56
+ refuse(
57
+ "Claude Code does not isolate the operator home yet. Remove --agent-home and --api-key-env; the vendor was not started.",
58
+ );
59
+ }
60
+
50
61
  const loaded = readAgentPrompt(options);
51
62
  if (!loaded.ok) {
52
63
  refuse(loaded.reason);
@@ -83,7 +94,7 @@ const transcript = transcriptFor(process.argv, options, {
83
94
  prompt,
84
95
  });
85
96
  const run = await runAgent({ file: bin, args, cwd });
86
- const extras = extrasFromClaude(run.stdout, outputFormat);
97
+ const extras = extrasFromClaude(run.stdout, outputFormat, cwd);
87
98
  transcript.write(run, extras);
88
99
  answer(run, bin, extras);
89
100
 
@@ -102,7 +113,13 @@ function refuse(reason) {
102
113
  function answer(run, binPath, extras) {
103
114
  writeContract(
104
115
  `${JSON.stringify(
105
- serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
116
+ serializeRun(run, {
117
+ name: NAME,
118
+ bin: binPath,
119
+ skills: extras.skills,
120
+ usage: extras.usage,
121
+ tools: extras.tools,
122
+ }),
106
123
  )}\n`,
107
124
  );
108
125
  process.exit(0);
@@ -9,24 +9,122 @@
9
9
  * with camelCase token fields (observed; not in the published json schema).
10
10
  * No cost field is documented for Cursor — `costUsd` stays null.
11
11
  *
12
- * `skills` is `null` when the stdout is not NDJSON we can parse (unknown),
13
- * `[]` when we parsed and found no skill reads.
12
+ * Tools: every `tool_call` (`<name>ToolCall`, args under it) counted once, on
13
+ * `started`; its `completed` closes the interval for the busy time
14
+ * (`timestamp_ms`). Observed on cursor-agent 2026.10.01: `webFetchToolCall`
15
+ * (`args.url`), `mcpToolCall` (`args.providerIdentifier` / `args.toolName`),
16
+ * `getMcpToolsToolCall` (a catalogue read, counted in `mcp` so a server that
17
+ * was only listed still shows up), `taskToolCall` (`args.description`),
18
+ * `shellToolCall` (`args.command`), and `path` / `targetDirectory` on the
19
+ * file tools.
20
+ *
21
+ * `skills` and `tools` are `null` when the stdout is not NDJSON we can parse
22
+ * (unknown); `skills` is `[]` when we parsed and found no skill reads.
14
23
  */
15
24
 
16
25
  import { readResult } from "@bluewombat/slot-kit";
26
+ import { busyMs, count, emptyTools, isOutside, urlsIn } from "./tool-facts.mjs";
17
27
 
18
28
  /**
19
29
  * @param {string} stdout
20
30
  * @param {string} outputFormat
21
- * @returns {{ skills: string[] | null, usage: import("@bluewombat/slot-kit").AgentUsage | null }}
31
+ * @param {string} cwd the directory the agent worked in
32
+ * @returns {import("@bluewombat/slot-kit").AgentExtras}
22
33
  */
23
- export function extrasFromCursor(stdout, outputFormat) {
34
+ export function extrasFromCursor(stdout, outputFormat, cwd) {
35
+ const streamed = outputFormat === "stream-json";
24
36
  return {
25
- skills: outputFormat === "stream-json" ? skillsFromCursor(stdout) : null,
37
+ skills: streamed ? skillsFromCursor(stdout) : null,
26
38
  usage: usageFromCursor(stdout),
39
+ tools: streamed ? toolsFromCursor(stdout, cwd) : null,
27
40
  };
28
41
  }
29
42
 
43
+ /**
44
+ * @param {string} stdout
45
+ * @param {string} cwd the directory the agent worked in
46
+ * @returns {import("@bluewombat/slot-kit").AgentTools | null}
47
+ */
48
+ export function toolsFromCursor(stdout, cwd) {
49
+ const tools = emptyTools();
50
+ /** @type {Map<string, number>} */
51
+ const open = new Map();
52
+ /** @type {[number, number][]} */
53
+ const intervals = [];
54
+ let parsedAny = false;
55
+ for (const line of stdout.split("\n")) {
56
+ let event;
57
+ try {
58
+ event = JSON.parse(line);
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
63
+ continue;
64
+ }
65
+ parsedAny = true;
66
+ if (
67
+ event.type !== "tool_call" ||
68
+ event.tool_call === null ||
69
+ typeof event.tool_call !== "object"
70
+ ) {
71
+ continue;
72
+ }
73
+ const callId = typeof event.call_id === "string" ? event.call_id : "";
74
+ const at = Number(event.timestamp_ms);
75
+ if (event.subtype === "completed") {
76
+ const started = open.get(callId);
77
+ if (started !== undefined && Number.isFinite(at)) {
78
+ intervals.push([started, at]);
79
+ }
80
+ open.delete(callId);
81
+ continue;
82
+ }
83
+ if (event.subtype !== "started" || open.has(callId)) {
84
+ continue;
85
+ }
86
+ if (Number.isFinite(at)) {
87
+ open.set(callId, at);
88
+ }
89
+ const key = Object.keys(event.tool_call).find((name) => name.endsWith("ToolCall"));
90
+ if (key === undefined) {
91
+ continue;
92
+ }
93
+ const name = key.slice(0, -"ToolCall".length);
94
+ const args = event.tool_call[key]?.args ?? {};
95
+ count(tools, name);
96
+ if (name === "webFetch" && typeof args.url === "string") {
97
+ tools.web.push(args.url);
98
+ } else if (name === "webSearch") {
99
+ const term = args.searchTerm ?? args.query;
100
+ if (typeof term === "string") {
101
+ tools.web.push(`search: ${term}`);
102
+ }
103
+ } else if (name === "shell") {
104
+ tools.web.push(...urlsIn(args.command).map((url) => `${url} (shell)`));
105
+ } else if (name === "mcp") {
106
+ tools.mcp.push(`${args.providerIdentifier ?? args.server ?? "?"}/${args.toolName ?? "?"}`);
107
+ } else if (name === "getMcpTools") {
108
+ const server = args.server ?? args.providerIdentifier ?? args.provider;
109
+ tools.mcp.push(
110
+ typeof server === "string" && server.length > 0 ? `${server}/getMcpTools` : "getMcpTools",
111
+ );
112
+ } else if (name === "task" && typeof args.description === "string") {
113
+ tools.subagents.push(args.description);
114
+ }
115
+ for (const path of [args.path, args.targetDirectory]) {
116
+ if (isOutside(path, cwd)) {
117
+ tools.outside.push(path);
118
+ }
119
+ }
120
+ }
121
+ if (!parsedAny) {
122
+ return null;
123
+ }
124
+ tools.busyMs = intervals.length === 0 ? null : busyMs(intervals);
125
+ return tools;
126
+ }
127
+
30
128
  /**
31
129
  * @param {string} stdout
32
130
  * @returns {string[] | null}
package/agents/cursor.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync, mkdirSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
4
5
  import {
5
6
  AGENT_OPTIONS,
6
7
  findExecutable,
@@ -25,6 +26,9 @@ import { extrasFromCursor } from "./cursor-extras.mjs";
25
26
  // --transcript-dir PATH write one file per turn. Off by default
26
27
  // --transcript-part NAME prompt, stdout, stderr, timing. Repeatable
27
28
  // --agent-arg VALUE appended before the prompt. Repeatable
29
+ // --agent-home DIR isolate this process: HOME is DIR, not the operator's
30
+ // --api-key-env NAME required with --agent-home. Read process.env[NAME];
31
+ // the value is never put on the argv
28
32
  // --id / --attempt / --context how a transcript is filed
29
33
 
30
34
  const NAME = "Cursor CLI";
@@ -70,17 +74,135 @@ const args = [
70
74
  prompt,
71
75
  ];
72
76
 
77
+ const isolation = cursorIsolation(options, cwd);
78
+ if (!isolation.ok) {
79
+ refuse(isolation.reason);
80
+ }
81
+
73
82
  const transcript = transcriptFor(process.argv, options, {
74
83
  slot: NAME,
75
84
  bin,
76
85
  args,
77
86
  prompt,
78
87
  });
79
- const run = await runAgent({ file: bin, args, cwd });
80
- const extras = extrasFromCursor(run.stdout, outputFormat);
88
+ const run = await runAgent({
89
+ file: bin,
90
+ args,
91
+ cwd,
92
+ ...(isolation.env === undefined ? {} : { env: isolation.env }),
93
+ });
94
+ const extras = extrasFromCursor(run.stdout, outputFormat, cwd);
81
95
  transcript.write(run, extras);
82
96
  answer(run, bin, extras);
83
97
 
98
+ /**
99
+ * `--agent-home` isolates the vendor process. The wrapper keeps the parent's
100
+ * HOME, so `findExecutable` still sees the operator's install directories.
101
+ * No link to the login keychain: auth is the named variable, held in memory.
102
+ *
103
+ * @param {import("@bluewombat/slot-kit").OptionValues} options
104
+ * @param {string} dir
105
+ * @returns {{ ok: true, env: NodeJS.ProcessEnv | undefined } | { ok: false, reason: string }}
106
+ */
107
+ function cursorIsolation(options, dir) {
108
+ const home = textOption(options, "agentHome");
109
+ const keyEnv = textOption(options, "apiKeyEnv");
110
+ if (home === undefined && keyEnv === undefined) {
111
+ return { ok: true, env: undefined };
112
+ }
113
+ if (home === undefined || keyEnv === undefined) {
114
+ return {
115
+ ok: false,
116
+ reason: "An isolated Cursor agent needs both --agent-home and --api-key-env.",
117
+ };
118
+ }
119
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv)) {
120
+ return {
121
+ ok: false,
122
+ reason: `--api-key-env must name an environment variable, not "${keyEnv}".`,
123
+ };
124
+ }
125
+ const apiKey = process.env[keyEnv];
126
+ if (apiKey === undefined || apiKey.length === 0) {
127
+ return {
128
+ ok: false,
129
+ reason: `The environment variable ${keyEnv} is empty. An isolated Cursor agent needs it, or the CLI tries to store credentials in the login keychain.`,
130
+ };
131
+ }
132
+ return { ok: true, env: isolatedVendorEnv(resolve(dir, home), apiKey) };
133
+ }
134
+
135
+ /**
136
+ * @param {string} agentHome
137
+ * @param {string} apiKey
138
+ * @returns {NodeJS.ProcessEnv}
139
+ */
140
+ function isolatedVendorEnv(agentHome, apiKey) {
141
+ mkdirSync(agentHome, { recursive: true });
142
+ const parentHome = homedir();
143
+ /** @type {NodeJS.ProcessEnv} */
144
+ const env = { ...process.env, HOME: agentHome };
145
+ if (process.env.PATH !== undefined) {
146
+ env.PATH = process.env.PATH;
147
+ }
148
+ env.CURSOR_API_KEY = apiKey;
149
+ env.AGENT_CLI_CREDENTIAL_STORE = "memory";
150
+ const gitConfig = parentGitConfig(parentHome);
151
+ if (gitConfig === undefined) {
152
+ delete env.GIT_CONFIG_GLOBAL;
153
+ } else {
154
+ env.GIT_CONFIG_GLOBAL = gitConfig;
155
+ }
156
+ const npmCache = process.env.npm_config_cache;
157
+ env.npm_config_cache =
158
+ npmCache !== undefined && npmCache.length > 0 ? npmCache : join(parentHome, ".npm");
159
+ const corepack = parentCorepack(parentHome);
160
+ if (corepack === undefined) {
161
+ delete env.COREPACK_HOME;
162
+ } else {
163
+ env.COREPACK_HOME = corepack;
164
+ }
165
+ return env;
166
+ }
167
+
168
+ /**
169
+ * The global git file the parent already resolves. `~/.gitconfig` wins over
170
+ * the XDG file, which is git's own order when both exist.
171
+ *
172
+ * @param {string} parentHome
173
+ * @returns {string | undefined}
174
+ */
175
+ function parentGitConfig(parentHome) {
176
+ const fromEnv = process.env.GIT_CONFIG_GLOBAL;
177
+ if (fromEnv !== undefined && fromEnv.length > 0 && existsSync(fromEnv)) {
178
+ return fromEnv;
179
+ }
180
+ const dot = join(parentHome, ".gitconfig");
181
+ if (existsSync(dot)) {
182
+ return dot;
183
+ }
184
+ const xdgRoot = process.env.XDG_CONFIG_HOME;
185
+ const xdg = join(
186
+ xdgRoot !== undefined && xdgRoot.length > 0 ? xdgRoot : join(parentHome, ".config"),
187
+ "git",
188
+ "config",
189
+ );
190
+ return existsSync(xdg) ? xdg : undefined;
191
+ }
192
+
193
+ /**
194
+ * @param {string} parentHome
195
+ * @returns {string | undefined}
196
+ */
197
+ function parentCorepack(parentHome) {
198
+ const fromEnv = process.env.COREPACK_HOME;
199
+ if (fromEnv !== undefined && fromEnv.length > 0 && existsSync(fromEnv)) {
200
+ return fromEnv;
201
+ }
202
+ const fallback = join(parentHome, ".cache", "node", "corepack");
203
+ return existsSync(fallback) ? fallback : undefined;
204
+ }
205
+
84
206
  /** @param {string} reason @returns {never} */
85
207
  function refuse(reason) {
86
208
  writeContract(`${JSON.stringify({ error: reason })}\n`);
@@ -96,7 +218,13 @@ function refuse(reason) {
96
218
  function answer(run, binPath, extras) {
97
219
  writeContract(
98
220
  `${JSON.stringify(
99
- serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
221
+ serializeRun(run, {
222
+ name: NAME,
223
+ bin: binPath,
224
+ skills: extras.skills,
225
+ usage: extras.usage,
226
+ tools: extras.tools,
227
+ }),
100
228
  )}\n`,
101
229
  );
102
230
  process.exit(0);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What both vendors' tool summaries share: the facts a stream yields once each
3
+ * tool call has been read in that vendor's own shape. Vendor-free.
4
+ */
5
+
6
+ import { isAbsolute, relative, resolve } from "node:path";
7
+
8
+ const URL_IN_TEXT = /https?:\/\/[^\s'"`<>|)\]]+/g;
9
+
10
+ /**
11
+ * URLs a shell command names: `curl`, `wget` or a script reaching the web.
12
+ * @param {unknown} command
13
+ * @returns {string[]}
14
+ */
15
+ export function urlsIn(command) {
16
+ return typeof command === "string"
17
+ ? [...command.matchAll(URL_IN_TEXT)].map((match) => match[0])
18
+ : [];
19
+ }
20
+
21
+ /**
22
+ * Whether a path a tool named is outside `cwd`. A skill file is not reported:
23
+ * it is read from the plugin directories Mason itself points the agent at.
24
+ * @param {unknown} path
25
+ * @param {unknown} cwd
26
+ * @returns {path is string}
27
+ */
28
+ export function isOutside(path, cwd) {
29
+ if (typeof path !== "string" || path.length === 0 || typeof cwd !== "string") {
30
+ return false;
31
+ }
32
+ if (/(?:^|[/\\])skills[/\\][^/\\]+[/\\]SKILL\.md$/i.test(path)) {
33
+ return false;
34
+ }
35
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path);
36
+ const fromCwd = relative(cwd, absolute);
37
+ return fromCwd === ".." || fromCwd.startsWith(`..${"/"}`) || isAbsolute(fromCwd);
38
+ }
39
+
40
+ /**
41
+ * Wall time covered by at least one `[start, end]` interval, in ms.
42
+ * @param {[number, number][]} intervals
43
+ * @returns {number}
44
+ */
45
+ export function busyMs(intervals) {
46
+ let total = 0;
47
+ /** @type {[number, number] | undefined} */
48
+ let current;
49
+ for (const [start, end] of [...intervals].sort((a, b) => a[0] - b[0])) {
50
+ if (current === undefined || start > current[1]) {
51
+ if (current !== undefined) {
52
+ total += current[1] - current[0];
53
+ }
54
+ current = [start, end];
55
+ } else {
56
+ current[1] = Math.max(current[1], end);
57
+ }
58
+ }
59
+ return current === undefined ? total : total + current[1] - current[0];
60
+ }
61
+
62
+ /**
63
+ * An empty summary, filled by a vendor's reader.
64
+ * @returns {import("@bluewombat/slot-kit").AgentTools}
65
+ */
66
+ export function emptyTools() {
67
+ return { calls: {}, busyMs: null, web: [], mcp: [], subagents: [], outside: [] };
68
+ }
69
+
70
+ /**
71
+ * One call more under `name`.
72
+ * @param {import("@bluewombat/slot-kit").AgentTools} tools
73
+ * @param {string} name
74
+ */
75
+ export function count(tools, name) {
76
+ tools.calls[name] = (tools.calls[name] ?? 0) + 1;
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluewombat/slots",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
6
6
  "keywords": [
@@ -53,6 +53,6 @@
53
53
  "test": "tsx --test $(find test -name '*.test.ts')"
54
54
  },
55
55
  "dependencies": {
56
- "@bluewombat/slot-kit": "^0.4.0"
56
+ "@bluewombat/slot-kit": "^0.6.0"
57
57
  }
58
58
  }