@syntax-syllogism/sfd 0.2.0 → 0.4.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,132 @@
1
+ export const COMMANDS = [
2
+ {
3
+ "name": "query",
4
+ "args": "<soql>",
5
+ "summary": "Run SOQL (all pages). --tooling, --all (include deleted), --limit <n>, --csv",
6
+ "agentTip": "\"<soql>\" --csv --limit 200: SOQL; --tooling, --all"
7
+ },
8
+ {
9
+ "name": "apex",
10
+ "args": "[file]",
11
+ "summary": "Run anonymous Apex from a file, -f <file>, or stdin. --log for the full debug log",
12
+ "agentTip": "-f <file> (or stdin): anonymous Apex; debug and fatal lines"
13
+ },
14
+ {
15
+ "name": "rest",
16
+ "args": "<path>",
17
+ "summary": "Call a REST path, e.g. /services/data/v67.0/limits. -X <method>, --body <json|@file>",
18
+ "agentTip": "<path>: REST; -X <method>, --body <json|@file>"
19
+ },
20
+ {
21
+ "name": "describe",
22
+ "args": "<sobject>",
23
+ "summary": "Compact describe. --tooling, --raw for the full describe",
24
+ "agentTip": "<sobject>: compact describe; --tooling, --raw",
25
+ "agentGroup": "describe"
26
+ },
27
+ {
28
+ "name": "list-metadata-types",
29
+ "args": "",
30
+ "summary": "List supported metadata types (cached per org and API version)",
31
+ "agentTip": ": supported metadata types (cached)",
32
+ "agentGroup": "describe"
33
+ },
34
+ {
35
+ "name": "list-metadata",
36
+ "args": "-m <Type>[,<Type>…]",
37
+ "summary": "List metadata names. -m repeatable, --folder <name>, --details for audit fields",
38
+ "agentTip": "-m <Type>: metadata names; --folder <name>, --details",
39
+ "agentGroup": "describe"
40
+ },
41
+ {
42
+ "name": "deploy",
43
+ "args": "",
44
+ "summary": "Deploy source. -d <path> (repeatable), -m <Type[:Name]> (repeatable),\n--check-only, -l <test level>, -t <test> (repeatable), -w <minutes>",
45
+ "agentTip": "-d <path> | -m <Type:Name>: deploy source",
46
+ "agentGroup": "source"
47
+ },
48
+ {
49
+ "name": "retrieve",
50
+ "args": "",
51
+ "summary": "Retrieve source. -d <path>, -m <Type[:Name]> or -x <package.xml>,\n-w <minutes>, --ignore-conflicts. No selector pulls tracked remote changes",
52
+ "agentTip": "[-d <path> | -m <Type:Name> | -x <manifest>]: retrieve source",
53
+ "agentGroup": "source"
54
+ },
55
+ {
56
+ "name": "tracking",
57
+ "args": "",
58
+ "summary": "Show source-tracked changes. --local | --remote, --conflicts (conflicts only)",
59
+ "agentTip": "[--local | --remote] [--conflicts]: source changes"
60
+ },
61
+ {
62
+ "name": "test",
63
+ "args": "",
64
+ "summary": "Run Apex tests. -n <class> (repeatable), -t <Class.method> (repeatable),\n-s <suite> (repeatable), -l <test level>, -c (coverage), -w <minutes>, --logs, --no-trace",
65
+ "agentTip": "-n <class> | -t <Class.method>: Apex tests; --logs, -c"
66
+ },
67
+ {
68
+ "name": "logs",
69
+ "args": "",
70
+ "summary": "List recent debug logs. --count/--limit <10>, --user <username>, --no-trace",
71
+ "agentTip": "[--count 10]: recent debug logs",
72
+ "agentGroup": "logs"
73
+ },
74
+ {
75
+ "name": "logs get",
76
+ "args": "<id|latest>",
77
+ "summary": "Print a filtered log. --full for raw text, --no-trace",
78
+ "agentTip": "latest: filtered debug log; --full for raw text",
79
+ "agentGroup": "logs"
80
+ },
81
+ {
82
+ "name": "org",
83
+ "args": "",
84
+ "summary": "Show the resolved org (username, id, instance, API version)",
85
+ "agentTip": ": resolved org details",
86
+ "agentGroup": "orgs"
87
+ },
88
+ {
89
+ "name": "org list",
90
+ "args": "[--all] [--status]",
91
+ "summary": "List authenticated orgs (local; --status pings)",
92
+ "agentTip": "[--all] [--status]: list local orgs; optional live checks",
93
+ "agentGroup": "orgs"
94
+ },
95
+ {
96
+ "name": "hook",
97
+ "args": "<agent|check|install|uninstall>",
98
+ "summary": "Integrate with Claude Code, Codex CLI or Gemini CLI",
99
+ "agentTip": "install --agent <claude|codex|gemini>: install rewrite hook",
100
+ "agentGroup": "agents"
101
+ },
102
+ {
103
+ "name": "agents-snippet",
104
+ "args": "[--write [file]]",
105
+ "summary": "Print agent instructions, or update AGENTS.md / CLAUDE.md",
106
+ "agentTip": "[--write [file]]: print or update agent instructions",
107
+ "agentGroup": "agents"
108
+ },
109
+ {
110
+ "name": "discover",
111
+ "args": "[--since 30d]",
112
+ "summary": "Report missed sfd opportunities in local agent transcripts",
113
+ "agentTip": "[--since 30d]: local transcript adoption report",
114
+ "agentGroup": "agents"
115
+ },
116
+ {
117
+ "name": "start | stop | status | reload",
118
+ "args": "",
119
+ "summary": "Manage the daemon. reload drops cached orgs and auth state",
120
+ "agentTip": ": daemon lifecycle"
121
+ }
122
+ ];
123
+
124
+ export function commandHelp() {
125
+ return COMMANDS.map(({ name, args, summary }) => {
126
+ const syntax = [name, args].filter(Boolean).join(" ");
127
+ const description = summary.replaceAll("\n", "\n ");
128
+ return syntax.length >= 22
129
+ ? ` ${syntax}\n ${description}`
130
+ : ` ${syntax.padEnd(22)}${description}`;
131
+ }).join("\n");
132
+ }
package/src/daemon.mjs CHANGED
@@ -4,13 +4,16 @@ import { PROTOCOL, VERSION } from "./version.mjs";
4
4
  import { AlreadyRunning, startServer } from "./server.mjs";
5
5
  import { paths } from "./paths.mjs";
6
6
  import { findSfRoot, sfInfo } from "./sf-root.mjs";
7
+ import { pathToFileURL } from "node:url";
7
8
 
8
9
  const log = (...args) => console.error(new Date().toISOString(), ...args);
9
10
  const p = paths();
10
11
  const sf = sfInfo(findSfRoot());
11
12
 
12
13
  // SFD_HANDLERS_MODULE swaps in stub handlers for tests that must not touch an org.
13
- const { createHandlers } = await import(process.env.SFD_HANDLERS_MODULE ?? "./handlers.mjs");
14
+ // import() needs a file URL for absolute Windows paths such as C:\\...
15
+ const handlersModule = process.env.SFD_HANDLERS_MODULE ? pathToFileURL(process.env.SFD_HANDLERS_MODULE).href : "./handlers.mjs";
16
+ const { createHandlers } = await import(handlersModule);
14
17
  const handlers = createHandlers(sf.root);
15
18
 
16
19
  // An sf upgrade rewrites its package.json; finish in-flight work and let the next call start fresh.
@@ -0,0 +1,25 @@
1
+ import { join } from "node:path";
2
+ import { files, lines, matches, complete, candidate, count } from "./shared.mjs";
3
+
4
+ export async function* read(options) {
5
+ const { home, since = new Date(0), stats } = options;
6
+ for await (const file of files(join(home, ".claude/projects"), ".jsonl", since, stats)) {
7
+ const pending = new Map();
8
+ for await (const entry of lines(file, stats)) {
9
+ const content = entry?.message?.content;
10
+ if (!Array.isArray(content)) { count(stats, "ignoredEntries"); continue; }
11
+ for (const item of content) {
12
+ if (item?.type === "tool_use" && item.name === "Bash") {
13
+ const command = item.input?.command;
14
+ if (!candidate(command)) continue;
15
+ const record = { agent: "claude", ts: new Date(entry.timestamp ?? NaN), command, cwd: entry.cwd, session: file };
16
+ if (matches(record, options)) pending.set(item.id, record);
17
+ } else if (item?.type === "tool_result" && pending.has(item.tool_use_id)) {
18
+ yield complete(pending.get(item.tool_use_id), entry.timestamp, item.content);
19
+ pending.delete(item.tool_use_id);
20
+ } else count(stats, "ignoredEntries");
21
+ }
22
+ }
23
+ yield* pending.values();
24
+ }
25
+ }
@@ -0,0 +1,28 @@
1
+ import { join } from "node:path";
2
+ import { files, lines, matches, complete, candidate, count, shellCommand } from "./shared.mjs";
3
+
4
+ export async function* read(options) {
5
+ const { home, since = new Date(0), stats } = options;
6
+ for await (const file of files(join(home, ".codex/sessions"), ".jsonl", since, stats)) {
7
+ const pending = new Map();
8
+ let cwd;
9
+ for await (const entry of lines(file, stats)) {
10
+ const payload = entry?.payload;
11
+ if (["session_meta", "turn_context"].includes(entry?.type)) { cwd = payload?.cwd ?? cwd; continue; }
12
+ if (entry?.type !== "response_item" || !payload) { count(stats, "ignoredEntries"); continue; }
13
+ if (payload.type === "function_call" && ["shell", "shell_command", "exec_command"].includes(payload.name)) {
14
+ let args;
15
+ try { args = JSON.parse(payload.arguments); }
16
+ catch { count(stats, "malformedEntries"); continue; }
17
+ const command = shellCommand(args);
18
+ if (!candidate(command)) continue;
19
+ const record = { agent: "codex", ts: new Date(entry.timestamp ?? NaN), command, cwd: args.workdir ?? cwd, session: file };
20
+ if (matches(record, options)) pending.set(payload.call_id, record);
21
+ } else if (payload.type === "function_call_output" && pending.has(payload.call_id)) {
22
+ yield complete(pending.get(payload.call_id), entry.timestamp, payload.output);
23
+ pending.delete(payload.call_id);
24
+ } else count(stats, "ignoredEntries");
25
+ }
26
+ yield* pending.values();
27
+ }
28
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, basename, dirname } from "node:path";
3
+ import { files, matches, candidate, count, outputChars } from "./shared.mjs";
4
+
5
+ export async function* read(options) {
6
+ const { home, since = new Date(0), stats } = options;
7
+ for await (const file of files(join(home, ".gemini/tmp"), ".json", since, stats)) {
8
+ if (basename(dirname(file)) !== "chats" && basename(file) !== "logs.json") continue;
9
+ let document;
10
+ // Gemini persists a JSON document, rather than a JSONL stream.
11
+ try { document = JSON.parse(await readFile(file, "utf8")); }
12
+ catch (error) { count(stats, error instanceof SyntaxError ? "malformedEntries" : "unreadableFiles"); continue; }
13
+ const messages = Array.isArray(document) ? document : document?.messages;
14
+ if (!Array.isArray(messages)) { count(stats, "ignoredEntries"); continue; }
15
+ for (const message of messages) {
16
+ if (!Array.isArray(message?.toolCalls)) { count(stats, "ignoredEntries"); continue; }
17
+ for (const tool of message.toolCalls) {
18
+ if (tool?.name !== "run_shell_command") { count(stats, "ignoredEntries"); continue; }
19
+ const command = tool.args?.command;
20
+ if (!candidate(command)) continue;
21
+ const record = {
22
+ agent: "gemini", ts: new Date(tool.timestamp ?? message.timestamp ?? NaN), command,
23
+ cwd: tool.args?.directory ?? message.cwd ?? document.cwd,
24
+ session: file
25
+ };
26
+ const chars = outputChars(tool.result);
27
+ if (chars !== undefined) record.outputChars = chars;
28
+ if (matches(record, options)) yield record;
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,181 @@
1
+ import { homedir } from "node:os";
2
+ import { basename } from "node:path";
3
+ import { rewrite, lex } from "../rewrite.mjs";
4
+ import { read as claude } from "./claude.mjs";
5
+ import { read as codex } from "./codex.mjs";
6
+ import { read as gemini } from "./gemini.mjs";
7
+ import { UsageError } from "../usage-error.mjs";
8
+ import { Worker } from "node:worker_threads";
9
+ import { COMMANDS } from "../commands.mjs";
10
+
11
+ const readers = { claude, codex, gemini };
12
+ // Only known command paths may enter the report; trailing words can be private values.
13
+ const sfPaths = [
14
+ "org list metadata-types", "org list metadata", "org list", "org display",
15
+ "org create scratch", "org create sandbox", "org login web", "org login jwt",
16
+ "apex list log", "apex get log", "apex run test", "apex run", "data query",
17
+ "project retrieve start", "project deploy start", "sobject describe", "api request rest",
18
+ "plugins"
19
+ ];
20
+ const commandPaths = {
21
+ sf: sfPaths,
22
+ sfdx: [...sfPaths, "force:org:list", "force:org:display", "force:data:soql:query",
23
+ "force:apex:execute", "force:apex:test:run", "force:source:deploy", "force:source:retrieve"],
24
+ sfd: COMMANDS.flatMap(({ name }) => name.split(" | "))
25
+ };
26
+
27
+ function subcommandName([executable, ...args]) {
28
+ if (!args.length || args[0].startsWith("-")) return "(no subcommand)";
29
+ const matches = commandPaths[executable].filter(path =>
30
+ path.split(" ").every((word, index) => args[index] === word));
31
+ return matches.sort((a, b) => b.split(" ").length - a.split(" ").length)[0]
32
+ ?? "(unrecognized subcommand)";
33
+ }
34
+
35
+ export function discoverOptions(values, now = Date.now()) {
36
+ const window = values.since ?? "30d";
37
+ if (!/^\d+[dh]$/.test(window)) throw new UsageError("--since must be a duration such as 30d or 12h.");
38
+ const since = new Date(now - Number(window.slice(0, -1)) * (window.endsWith("d") ? 86400000 : 3600000));
39
+ if (!Number.isFinite(+since)) throw new UsageError("--since is too large.");
40
+ const agents = [...new Set(values.agent ?? Object.keys(readers))];
41
+ if (agents.some(agent => !Object.hasOwn(readers, agent))) throw new UsageError("--agent must be claude, codex or gemini.");
42
+ const top = Number(values.top ?? 10);
43
+ if (!Number.isSafeInteger(top) || top <= 0) throw new UsageError("--top must be a positive integer.");
44
+ return { since, window, agents, top, project: values.project };
45
+ }
46
+
47
+ function invocation(command) {
48
+ let tokens;
49
+ try { tokens = lex(command); }
50
+ catch {
51
+ // The rewriter will retain its exact syntax/expansion skip reason.
52
+ const match = /^(?:\s*cd\s+\S+\s*&&)?\s*((?:\w+=\S+\s+)*)(sf|sfd|sfdx)\b(.*)/s.exec(command);
53
+ return match ? [match[2], ...match[3].trim().split(/\s+/)] : undefined;
54
+ }
55
+ const found = [];
56
+ let start = true;
57
+ for (let i = 0; i < tokens.length; i++) {
58
+ const token = tokens[i];
59
+ if (token.op) { start = ["&&", "||", ";", "|", "\n", "&"].includes(token.op); continue; }
60
+ if (!start) continue;
61
+ if (/^\w+=/.test(token.value) || ["env", "rtk", "proxy", "command"].includes(token.value)) continue;
62
+ start = false;
63
+ if (["sf", "sfd", "sfdx"].includes(basename(token.value))) {
64
+ const words = [basename(token.value)];
65
+ for (let j = i + 1; j < tokens.length && !tokens[j].op; j++) words.push(tokens[j].value);
66
+ found.push(words);
67
+ }
68
+ }
69
+ return found.find(words => words[0] !== "sfd") ?? found[0];
70
+ }
71
+
72
+ function reportReason(reason) {
73
+ if (reason.startsWith("unsupported result format ")) return "unsupported result format";
74
+ const flagReason = /^(unsupported flag|duplicate flag|missing value for|unsupported value for|unsupported boolean value) (.*)$/s.exec(reason);
75
+ if (!flagReason) return reason;
76
+ const flag = flagReason[2].split("=")[0];
77
+ return `${flagReason[1]}${/^(?:--[A-Za-z][A-Za-z0-9-]*|-[A-Za-z])$/.test(flag) ? ` ${flag}` : ""}`;
78
+ }
79
+
80
+ export function classify(command) {
81
+ const words = invocation(command);
82
+ if (!words) return;
83
+ const subcommand = subcommandName(words);
84
+ if (words[0] === "sfd") return { classification: "adopted", subcommand };
85
+ if (words[0] === "sfdx" && words[1]?.startsWith("force:")) return { classification: "legacy", subcommand };
86
+ const result = rewrite(command);
87
+ if (result.command) return { classification: "rewritable", subcommand };
88
+ if (result.skip === "unsupported sf command") return { classification: "unsupported", subcommand };
89
+ return { classification: "skipped", subcommand, reason: reportReason(result.skip) };
90
+ }
91
+
92
+ const median = values => {
93
+ if (!values.length) return null;
94
+ values.sort((a, b) => a - b);
95
+ const mid = Math.floor(values.length / 2);
96
+ return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2;
97
+ };
98
+ const ranked = map => [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
99
+
100
+ function scanAgent(agent, options) {
101
+ // Independent JSON parsers keep large agent histories off one CPU core.
102
+ return new Promise((resolve, reject) => {
103
+ const worker = new Worker(new URL("./worker.mjs", import.meta.url), { workerData: { agent, ...options } });
104
+ let result;
105
+ worker.on("message", message => { result = message; });
106
+ worker.on("error", reject);
107
+ worker.on("exit", code => {
108
+ if (code !== 0 || !result) reject(new Error(`Discover ${agent} reader exited with code ${code}`));
109
+ else resolve(result);
110
+ });
111
+ });
112
+ }
113
+
114
+ export async function discover({ home = homedir(), agents = Object.keys(readers), since = new Date(0), window = "all history", project, top = 10 } = {}) {
115
+ const report = {
116
+ since: since.toISOString(), window, agents, project: project ?? null, top,
117
+ sfCalls: 0, sessions: 0,
118
+ counts: { rewritable: 0, skipped: 0, unsupported: 0, legacy: 0, adopted: 0 },
119
+ estimates: { timeSavedMs: 0, durationSamples: 0, outputChars: 0, outputSamples: 0, outputTokens: 0 },
120
+ diagnostics: { malformedEntries: 0, ignoredEntries: 0, unreadableFiles: 0 }
121
+ };
122
+ const sessions = new Set(), groups = new Map(), reasons = new Map(), unsupported = new Map(), rewritable = new Map();
123
+ const scans = await Promise.all(agents.map(agent => scanAgent(agent, { home, since, project })));
124
+ for (const { observations, stats } of scans) {
125
+ for (const [key, value] of Object.entries(stats)) report.diagnostics[key] += value;
126
+ for (const record of observations) {
127
+ const classification = record.classification;
128
+ const { classification: kind, subcommand, reason } = classification;
129
+ report.counts[kind]++;
130
+ if (kind !== "adopted") report.sfCalls++;
131
+ sessions.add(`${record.agent}:${record.session}`);
132
+ const key = JSON.stringify([kind, subcommand, reason]);
133
+ if (!groups.has(key)) groups.set(key, { ...classification, count: 0, durations: [], sizes: [] });
134
+ const group = groups.get(key);
135
+ group.count++;
136
+ if (record.durationMs !== undefined) group.durations.push(record.durationMs);
137
+ if (record.outputChars !== undefined) group.sizes.push(record.outputChars);
138
+ if (kind === "rewritable") {
139
+ rewritable.set(subcommand, (rewritable.get(subcommand) ?? 0) + 1);
140
+ if (record.durationMs !== undefined) {
141
+ report.estimates.durationSamples++;
142
+ report.estimates.timeSavedMs += Math.max(0, record.durationMs - 300);
143
+ }
144
+ if (record.outputChars !== undefined) {
145
+ report.estimates.outputSamples++;
146
+ report.estimates.outputChars += record.outputChars;
147
+ }
148
+ } else if (kind === "skipped") reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
149
+ else if (["unsupported", "legacy"].includes(kind)) unsupported.set(subcommand, (unsupported.get(subcommand) ?? 0) + 1);
150
+ }
151
+ }
152
+ report.sessions = sessions.size;
153
+ report.estimates.outputTokens = report.estimates.outputChars / 4;
154
+ report.commands = [...groups.values()].map(({ durations, sizes, ...group }) => ({
155
+ ...group, medianDurationMs: median(durations), durationSamples: durations.length,
156
+ medianOutputChars: median(sizes), medianOutputTokens: sizes.length ? median(sizes) / 4 : null, outputSamples: sizes.length
157
+ })).sort((a, b) => b.count - a.count || a.subcommand.localeCompare(b.subcommand));
158
+ report.skipReasons = ranked(reasons);
159
+ report.unsupported = ranked(unsupported);
160
+ report.rewritable = ranked(rewritable);
161
+ return report;
162
+ }
163
+
164
+ export function formatReport(report) {
165
+ const list = items => items.slice(0, report.top).map(item => `${item.name} ${item.count}`).join(" · ") || "(none)";
166
+ const { counts: c, estimates: e, diagnostics: d } = report;
167
+ const time = e.durationSamples ? `~${(e.timeSavedMs / 60000).toFixed(1)} min saved (${e.durationSamples} samples)` : "time unknown";
168
+ const output = e.outputSamples ? `~${Math.round(e.outputTokens)} output tokens (${e.outputSamples} samples)` : "output unknown";
169
+ return [
170
+ `sfd discover: ${report.window}, ${report.agents.length} agents, ${report.sfCalls} sf/sfdx calls in ${report.sessions} sessions`,
171
+ ` rewritable now ${c.rewritable} (${time}; ${output})`,
172
+ ` skipped ${c.skipped} ${list(report.skipReasons)}`,
173
+ ` not in sfd ${c.unsupported + c.legacy} ${list(report.unsupported)} (legacy ${c.legacy})`,
174
+ ` already sfd ${c.adopted}`,
175
+ `Top rewritable: ${list(report.rewritable)}`,
176
+ `Scan: ${d.malformedEntries} malformed, ${d.ignoredEntries} ignored entries, ${d.unreadableFiles} unreadable files.`,
177
+ "Durations include permission wait; savings assume 0.3 s/call. Tokens are chars/4.",
178
+ "Output tokens are potential savings before sfd output, which is not measured. Missing samples are not extrapolated.",
179
+ "Counts classify shell tool calls once; compound calls use the first sf/sfdx invocation."
180
+ ].join("\n");
181
+ }
@@ -0,0 +1,79 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
4
+ import { join, relative, resolve, isAbsolute } from "node:path";
5
+
6
+ export function count(stats, key) {
7
+ if (stats) stats[key] = (stats[key] ?? 0) + 1;
8
+ }
9
+
10
+ export async function* files(root, extension, since, stats) {
11
+ let entries;
12
+ try { entries = await readdir(root, { withFileTypes: true }); }
13
+ catch (error) {
14
+ if (error.code !== "ENOENT") count(stats, "unreadableFiles");
15
+ return;
16
+ }
17
+ for (const entry of entries) {
18
+ const path = join(root, entry.name);
19
+ if (entry.isDirectory()) yield* files(path, extension, since, stats);
20
+ else if (entry.isFile() && entry.name.endsWith(extension)) {
21
+ try {
22
+ if ((await stat(path)).mtimeMs >= Number(since)) yield path;
23
+ } catch { count(stats, "unreadableFiles"); }
24
+ }
25
+ }
26
+ }
27
+
28
+ export async function* lines(file, stats) {
29
+ const stream = createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 });
30
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
31
+ // Forward stream errors into the iterator, including files removed mid-scan.
32
+ stream.on("error", error => reader.close());
33
+ try {
34
+ for await (const line of reader) {
35
+ if (!line.trim()) continue;
36
+ try { yield JSON.parse(line); }
37
+ catch { count(stats, "malformedEntries"); }
38
+ }
39
+ if (stream.errored) count(stats, "unreadableFiles");
40
+ } catch { count(stats, "unreadableFiles"); }
41
+ finally { reader.close(); stream.destroy(); }
42
+ }
43
+
44
+ export function matches(record, { since, project, stats }) {
45
+ if (record.ts == null || !Number.isFinite(+record.ts)) {
46
+ count(stats, "malformedEntries");
47
+ return false;
48
+ }
49
+ if (+record.ts < +since) return false;
50
+ if (!project) return true;
51
+ if (typeof record.cwd !== "string") return false;
52
+ const path = relative(resolve(project), resolve(record.cwd));
53
+ return !isAbsolute(path) && path !== ".." && !path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`);
54
+ }
55
+
56
+ export function outputChars(output) {
57
+ if (output === undefined || output === null) return undefined;
58
+ return (typeof output === "string" ? output : JSON.stringify(output)).length;
59
+ }
60
+
61
+ export function complete(record, timestamp, output) {
62
+ const duration = +new Date(timestamp) - +record.ts;
63
+ return {
64
+ ...record,
65
+ ...(Number.isFinite(duration) && duration >= 0 ? { durationMs: duration } : {}),
66
+ ...(outputChars(output) !== undefined ? { outputChars: outputChars(output) } : {})
67
+ };
68
+ }
69
+
70
+ export function shellCommand(args) {
71
+ const command = args?.command ?? args?.cmd;
72
+ if (typeof command === "string") return command;
73
+ if (!Array.isArray(command) || !command.every(v => typeof v === "string")) return;
74
+ if (["bash", "sh", "/bin/bash", "/bin/sh"].includes(command[0]) && ["-lc", "-c"].includes(command[1]) && command.length === 3) return command[2];
75
+ // Preserve argv boundaries for the literal-shell rewriter.
76
+ return command.map(v => `'${v.replaceAll("'", "'\\''")}'`).join(" ");
77
+ }
78
+
79
+ export const candidate = command => typeof command === "string" && /\b(?:sf|sfd|sfdx)\b/.test(command);
@@ -0,0 +1,17 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { read as claude } from "./claude.mjs";
3
+ import { read as codex } from "./codex.mjs";
4
+ import { read as gemini } from "./gemini.mjs";
5
+ import { classify } from "./index.mjs";
6
+
7
+ const stats = {};
8
+ const observations = [];
9
+ const { agent, ...options } = workerData;
10
+ for await (const record of ({ claude, codex, gemini })[agent]({ ...options, stats })) {
11
+ const classification = classify(record.command);
12
+ if (classification) observations.push({
13
+ agent, session: record.session, durationMs: record.durationMs,
14
+ outputChars: record.outputChars, classification
15
+ });
16
+ }
17
+ parentPort.postMessage({ observations, stats });