@kenkaiiii/ggcoder 5.67.1 → 5.68.1

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.
Files changed (64) hide show
  1. package/dist/app-sidecar.js +121 -3
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/core/agent-session.d.ts +50 -3
  4. package/dist/core/agent-session.js +344 -21
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/ask-user.d.ts +90 -8
  7. package/dist/core/ask-user.js +124 -13
  8. package/dist/core/ask-user.js.map +1 -1
  9. package/dist/core/cache-diagnostics.d.ts +62 -0
  10. package/dist/core/cache-diagnostics.js +165 -0
  11. package/dist/core/cache-diagnostics.js.map +1 -0
  12. package/dist/core/compaction/plan-step-policy.d.ts +46 -0
  13. package/dist/core/compaction/plan-step-policy.js +57 -0
  14. package/dist/core/compaction/plan-step-policy.js.map +1 -0
  15. package/dist/core/destructive-git-guard.d.ts +63 -0
  16. package/dist/core/destructive-git-guard.js +831 -0
  17. package/dist/core/destructive-git-guard.js.map +1 -0
  18. package/dist/core/event-bus.d.ts +3 -0
  19. package/dist/core/event-bus.js +5 -0
  20. package/dist/core/event-bus.js.map +1 -1
  21. package/dist/core/keep-awake.d.ts +88 -0
  22. package/dist/core/keep-awake.js +251 -0
  23. package/dist/core/keep-awake.js.map +1 -0
  24. package/dist/core/package-threats.d.ts +18 -0
  25. package/dist/core/package-threats.js +168 -0
  26. package/dist/core/package-threats.js.map +1 -0
  27. package/dist/core/session-manager.d.ts +13 -1
  28. package/dist/core/session-manager.js +38 -18
  29. package/dist/core/session-manager.js.map +1 -1
  30. package/dist/core/session-summary-index.d.ts +37 -0
  31. package/dist/core/session-summary-index.js +172 -0
  32. package/dist/core/session-summary-index.js.map +1 -0
  33. package/dist/core/settings-manager.d.ts +2 -0
  34. package/dist/core/settings-manager.js +8 -0
  35. package/dist/core/settings-manager.js.map +1 -1
  36. package/dist/core/shell-threats-popular-packages.d.ts +11 -0
  37. package/dist/core/shell-threats-popular-packages.js +675 -0
  38. package/dist/core/shell-threats-popular-packages.js.map +1 -0
  39. package/dist/core/shell-threats.d.ts +8 -0
  40. package/dist/core/shell-threats.js +186 -0
  41. package/dist/core/shell-threats.js.map +1 -0
  42. package/dist/core/stream-rules.d.ts +30 -0
  43. package/dist/core/stream-rules.js +151 -0
  44. package/dist/core/stream-rules.js.map +1 -0
  45. package/dist/motion-agent/motion-check-tool.d.ts +7 -0
  46. package/dist/motion-agent/motion-check-tool.js +80 -30
  47. package/dist/motion-agent/motion-check-tool.js.map +1 -1
  48. package/dist/motion-agent/motion-review.d.ts +5 -2
  49. package/dist/motion-agent/motion-review.js +7 -4
  50. package/dist/motion-agent/motion-review.js.map +1 -1
  51. package/dist/tools/ask-user.js +3 -3
  52. package/dist/tools/ask-user.js.map +1 -1
  53. package/dist/tools/bash.js +37 -0
  54. package/dist/tools/bash.js.map +1 -1
  55. package/dist/tools/index.js +1 -1
  56. package/dist/tools/index.js.map +1 -1
  57. package/dist/tools/task-send.d.ts +1 -1
  58. package/dist/tools/task-send.js +15 -1
  59. package/dist/tools/task-send.js.map +1 -1
  60. package/dist/ui/components/ActivityIndicator.js +1 -0
  61. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  62. package/dist/ui/hooks/useAgentLoop.d.ts +1 -1
  63. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  64. package/package.json +4 -4
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Local, deterministic shell-threat rules. Run against every command the
3
+ * agent is about to execute (bash tool, task_send input) and refuse the
4
+ * clearly-dangerous shapes: pipe-to-shell installers, reverse shells, secret
5
+ * exfiltration, lookalike (IDN/punycode) hosts and terminal-escape tricks.
6
+ *
7
+ * These are regexes over the full command text, not a shell parser. Known
8
+ * trade-off: quoted text inside `echo`/`printf` (e.g. `echo "curl x | sh"`)
9
+ * may be flagged too. That false positive is acceptable — the agent can write
10
+ * such text to a file with the write tool instead.
11
+ */
12
+ import { extractCommandHosts, hostFromUrl } from "./network-guard.js";
13
+ const SHELLS = "(?:sudo\\s+)?(?:[\\w./-]*/)?(?:sh|bash|zsh|dash|ksh|python[\\d.]*|node|perl|ruby)\\b";
14
+ const FETCHERS = "\\b(?:curl|wget)\\b";
15
+ const PIPE_TO_SHELL = [
16
+ // curl … | sh (any number of intermediate pipeline stages)
17
+ // An interpreter given an inline script (-c/-e/-m/-p) or a script file reads
18
+ // stdin as data (`curl … | python -m json.tool`), so that is not flagged.
19
+ new RegExp(`${FETCHERS}[^;&\\n]*\\|\\s*${SHELLS}(?!\\s+(?:-[cmepE]\\b|--eval\\b|--print\\b|[^-\\s|;&]))`, "i"),
20
+ // bash <(curl …)
21
+ new RegExp(`${SHELLS}\\s+<\\(\\s*(?:curl|wget)\\b`, "i"),
22
+ // sh -c "$(curl …)" / $(wget …) / `curl …` executed
23
+ new RegExp(`${SHELLS}\\s+(?:-\\w+\\s+)*["']?\\$\\(\\s*(?:curl|wget)\\b`, "i"),
24
+ new RegExp(`${SHELLS}\\s+(?:-\\w+\\s+)*["']?\`\\s*(?:curl|wget)\\b`, "i"),
25
+ // eval "$(curl …)"
26
+ /\beval\s+["']?\$\(\s*(?:curl|wget)\b/i,
27
+ // PowerShell: iex (iwr …) / irm … | iex / Invoke-Expression
28
+ /\b(?:iex|invoke-expression)\b[\s(]*(?:\(?\s*)(?:iwr|irm|invoke-webrequest|invoke-restmethod|\(?new-object\s+net\.webclient)/i,
29
+ /\b(?:iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]*\|\s*(?:iex|invoke-expression)\b/i,
30
+ /\binvoke-expression\b/i,
31
+ ];
32
+ const REVERSE_SHELL = [
33
+ /\/dev\/(?:tcp|udp)\//i,
34
+ // Case-sensitive: nc flags are case-significant (`-C` is CRLF, not `-c`).
35
+ /\b(?:nc|ncat|netcat)\b[^|;&\n]*\s(?:-[a-zA-Z]*[ec]\b|--exec\b|--sh-exec\b|-e\S|-c\S)/,
36
+ /\bsocat\b[^\n]*\b(?:exec|system):/i,
37
+ /\bbash\s+-i\s*>&/i,
38
+ ];
39
+ /** python -c / perl -e one-liners that open a socket and connect/spawn. */
40
+ function isScriptReverseShell(command) {
41
+ if (!/\b(?:python[\d.]*\s+-c|perl\s+-e)\b/i.test(command))
42
+ return false;
43
+ return /\bsocket\b/i.test(command) && /\b(?:connect|subprocess|exec)\b/i.test(command);
44
+ }
45
+ const SECRET_SOURCES = [
46
+ /~\/\.ssh\//,
47
+ /\.ssh\//,
48
+ /\bid_(?:rsa|ed25519|ecdsa|dsa)\b/,
49
+ /\.aws\/credentials\b/,
50
+ /\.gg\/auth\.json\b/,
51
+ /\.netrc\b/,
52
+ /\.npmrc\b/,
53
+ // `.env` / `.env.local` as a path component, but not `.env.example`/`.env.sample`.
54
+ /(?:^|[\s/'"=@<])\.env(?!\.(?:example|sample|template|dist)\b)(?:\.[\w-]+)?(?=$|[\s'"|;&)>])/,
55
+ /\bsecurity\s+find-(?:generic|internet)-password\b/,
56
+ /(?:^|[;&|(]\s*)(?:printenv|env)\s*(?:\||$)/m,
57
+ ];
58
+ const NETWORK_SINKS = [
59
+ // Case-sensitive: `-f`/`-t`/`-D` are not upload flags. A short cluster such
60
+ // as `-sd` counts only when the data flag is last (it takes the value).
61
+ /\bcurl\b[^\n]*\s(?:-[a-zA-Z]*[dFT]|--data(?:-\w+)?|--form(?:-string)?|--upload-file)(?=[\s=@'"]|$)/,
62
+ /\bcurl\b[^\n]*\s@\S/i,
63
+ /\bwget\b[^\n]*--post-(?:data|file)\b/i,
64
+ /\b(?:nc|ncat|netcat)\s/i,
65
+ /\b(?:scp|rsync)\b[^\n]*\s[\w.@-]+:/i,
66
+ /\bxargs\b[^\n]*\b(?:curl|wget)\b/i,
67
+ /\bfind\b[^\n]*-exec\s+(?:curl|wget)\b/i,
68
+ ];
69
+ // eslint-disable-next-line no-control-regex -- matching ESC is the point
70
+ const TERMINAL_TRICK = /[\u001b\u202a-\u202e\u2066-\u2069\u200b-\u200d\u2060\ufeff]/u;
71
+ function codepoint(ch) {
72
+ return `U+${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}`;
73
+ }
74
+ /** Programs whose bare dotted operands are hostnames (`ping аpple.com`). */
75
+ const NETWORK_PROGRAMS = new Set([
76
+ "curl",
77
+ "wget",
78
+ "ping",
79
+ "ssh",
80
+ "scp",
81
+ "sftp",
82
+ "rsync",
83
+ "git",
84
+ "nc",
85
+ "ncat",
86
+ "netcat",
87
+ "telnet",
88
+ "ftp",
89
+ "dig",
90
+ "nslookup",
91
+ "host",
92
+ "http",
93
+ "https",
94
+ ]);
95
+ /**
96
+ * Candidate hostnames: URL authorities anywhere, plus bare dotted operands of
97
+ * network programs only — so non-ASCII file names (`cat résumé.pdf`) pass.
98
+ */
99
+ function candidateHosts(command) {
100
+ const out = new Set();
101
+ for (const m of command.matchAll(/[a-z][a-z0-9+.-]*:\/\/(?:[^@\s/'"]*@)?([^\s/:'"?#]+)/giu)) {
102
+ if (m[1])
103
+ out.add(m[1].toLowerCase());
104
+ }
105
+ for (const segment of command.split(/(?:&&|\|\||[;|\n])/)) {
106
+ const tokens = segment.split(/[\s'"()<>]+/).filter((t) => t.length > 0);
107
+ if (tokens[0] === "sudo")
108
+ tokens.shift();
109
+ const program = (tokens[0] ?? "").split("/").pop() ?? "";
110
+ if (!NETWORK_PROGRAMS.has(program))
111
+ continue;
112
+ for (const token of tokens.slice(1)) {
113
+ const bare = token.replace(/^[\w.-]*@/, "").split(/[/:?#]/)[0] ?? "";
114
+ if (/^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u.test(bare) && /\p{L}/u.test(bare)) {
115
+ out.add(bare.toLowerCase());
116
+ }
117
+ }
118
+ }
119
+ for (const host of extractCommandHosts(command))
120
+ out.add(host);
121
+ return [...out];
122
+ }
123
+ function lookalikeThreats(command) {
124
+ const threats = [];
125
+ const seen = new Set();
126
+ for (const host of candidateHosts(command)) {
127
+ const nonAscii = /[^\p{ASCII}]/u.test(host);
128
+ const punycodeLabel = host.split(".").some((label) => label.startsWith("xn--"));
129
+ if (!nonAscii && !punycodeLabel)
130
+ continue;
131
+ const ascii = nonAscii ? (hostFromUrl(`http://${host}`) ?? host) : host;
132
+ if (seen.has(ascii))
133
+ continue;
134
+ seen.add(ascii);
135
+ threats.push({
136
+ rule: "lookalike-host",
137
+ severity: "block",
138
+ detail: nonAscii
139
+ ? `host "${host}" contains non-ASCII characters (resolves as ${ascii}) — likely a lookalike of a real domain`
140
+ : `host "${host}" uses a punycode (xn--) label — likely a lookalike of a real domain`,
141
+ });
142
+ }
143
+ return threats;
144
+ }
145
+ export function checkShellThreats(command) {
146
+ const threats = [];
147
+ const trick = TERMINAL_TRICK.exec(command);
148
+ if (trick) {
149
+ threats.push({
150
+ rule: "terminal-tricks",
151
+ severity: "block",
152
+ detail: `command contains invisible/control character ${codepoint(trick[0])} (ANSI escape, bidi override or zero-width) that can hide what actually runs`,
153
+ });
154
+ }
155
+ if (PIPE_TO_SHELL.some((re) => re.test(command))) {
156
+ threats.push({
157
+ rule: "pipe-to-shell",
158
+ severity: "block",
159
+ detail: "remote content is executed directly by a shell/interpreter. Download it to a file first " +
160
+ "(e.g. `curl -fsSL -o install.sh <url>`), read it, then run it.",
161
+ });
162
+ }
163
+ if (REVERSE_SHELL.some((re) => re.test(command)) || isScriptReverseShell(command)) {
164
+ threats.push({
165
+ rule: "reverse-shell",
166
+ severity: "block",
167
+ detail: "command matches a reverse-shell pattern (remote shell access over a socket)",
168
+ });
169
+ }
170
+ if (SECRET_SOURCES.some((re) => re.test(command)) &&
171
+ NETWORK_SINKS.some((re) => re.test(command))) {
172
+ threats.push({
173
+ rule: "secret-exfil",
174
+ severity: "block",
175
+ detail: "command reads secret material (keys, credentials, .env, environment) and sends data over the network",
176
+ });
177
+ }
178
+ threats.push(...lookalikeThreats(command));
179
+ return threats;
180
+ }
181
+ /** First blocking threat as a refusal message, or null when the command may run. */
182
+ export function shellThreatBlockMessage(command) {
183
+ const threat = checkShellThreats(command).find((t) => t.severity === "block");
184
+ return threat ? `Blocked by shell safety check (${threat.rule}): ${threat.detail}` : null;
185
+ }
186
+ //# sourceMappingURL=shell-threats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell-threats.js","sourceRoot":"","sources":["../../src/core/shell-threats.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Loads stream rules (regex guards matched against the model's streaming
3
+ * output — see gg-agent `stream-rules.ts`) from markdown files:
4
+ *
5
+ * ~/.gg/rules/*.md user rules (trusted)
6
+ * <cwd>/.gg/rules/*.md project rules (untrusted; override user rules by name)
7
+ *
8
+ * No rules ship built in. Example `.gg/rules/no-console-log.md`:
9
+ *
10
+ * ---
11
+ * name: no-console-log
12
+ * trigger: console\.log\(
13
+ * scope: tool # text | tool | both (default both)
14
+ * tools: [edit, write] # optional; restricts tool-scope matching
15
+ * ---
16
+ * Do not add console.log calls. Use the project logger (`log()` from
17
+ * core/logger.ts) instead.
18
+ *
19
+ * `trigger` is a JavaScript regex source; `/source/flags` form is accepted
20
+ * with flags from `imsu`. The body is the reminder shown to the model when the
21
+ * rule fires (trimmed, capped at 1,000 chars). Project triggers longer than
22
+ * 300 chars or with nested quantifiers (catastrophic-backtracking risk) are
23
+ * skipped. Invalid files are skipped with a WARN log; loading never throws.
24
+ */
25
+ import type { StreamRule } from "@kenkaiiii/gg-agent";
26
+ export declare const MAX_STREAM_RULES = 50;
27
+ export declare const MAX_STREAM_RULE_REMINDER_CHARS = 1000;
28
+ export declare const MAX_PROJECT_TRIGGER_CHARS = 300;
29
+ /** Load user + project stream rules. Project rules override user rules with the same name. */
30
+ export declare function loadStreamRules(cwd: string, home?: string): Promise<StreamRule[]>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Loads stream rules (regex guards matched against the model's streaming
3
+ * output — see gg-agent `stream-rules.ts`) from markdown files:
4
+ *
5
+ * ~/.gg/rules/*.md user rules (trusted)
6
+ * <cwd>/.gg/rules/*.md project rules (untrusted; override user rules by name)
7
+ *
8
+ * No rules ship built in. Example `.gg/rules/no-console-log.md`:
9
+ *
10
+ * ---
11
+ * name: no-console-log
12
+ * trigger: console\.log\(
13
+ * scope: tool # text | tool | both (default both)
14
+ * tools: [edit, write] # optional; restricts tool-scope matching
15
+ * ---
16
+ * Do not add console.log calls. Use the project logger (`log()` from
17
+ * core/logger.ts) instead.
18
+ *
19
+ * `trigger` is a JavaScript regex source; `/source/flags` form is accepted
20
+ * with flags from `imsu`. The body is the reminder shown to the model when the
21
+ * rule fires (trimmed, capped at 1,000 chars). Project triggers longer than
22
+ * 300 chars or with nested quantifiers (catastrophic-backtracking risk) are
23
+ * skipped. Invalid files are skipped with a WARN log; loading never throws.
24
+ */
25
+ import fs from "node:fs/promises";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { parseFrontmatter } from "./frontmatter.js";
29
+ import { log } from "./logger.js";
30
+ export const MAX_STREAM_RULES = 50;
31
+ export const MAX_STREAM_RULE_REMINDER_CHARS = 1000;
32
+ export const MAX_PROJECT_TRIGGER_CHARS = 300;
33
+ /** Heuristic for nested quantifiers like `(a+)+`, `(\w*)*`, `(x+){2,}`. */
34
+ const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)[+*{]/;
35
+ const REGEX_LITERAL = /^\/(.+)\/([imsu]*)$/s;
36
+ async function listRuleFiles(dir) {
37
+ try {
38
+ const entries = await fs.readdir(dir, { withFileTypes: true });
39
+ return entries
40
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".md"))
41
+ .map((e) => path.join(dir, e.name))
42
+ .sort();
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ function parseTools(raw) {
49
+ if (raw === undefined)
50
+ return undefined;
51
+ const tools = raw
52
+ .replace(/^\[|\]$/g, "")
53
+ .split(/[\s,]+/)
54
+ .map((t) => t
55
+ .replace(/^-+$/, "")
56
+ .replace(/^["']|["']$/g, "")
57
+ .trim())
58
+ .filter(Boolean);
59
+ return tools.length > 0 ? tools : undefined;
60
+ }
61
+ function parseScope(raw) {
62
+ if (raw === undefined || raw.trim() === "")
63
+ return "both";
64
+ const scope = raw.trim().toLowerCase();
65
+ return scope === "text" || scope === "tool" || scope === "both" ? scope : null;
66
+ }
67
+ function compileTrigger(trigger, origin) {
68
+ if (origin === "project") {
69
+ if (trigger.length > MAX_PROJECT_TRIGGER_CHARS) {
70
+ return `trigger exceeds ${MAX_PROJECT_TRIGGER_CHARS} chars`;
71
+ }
72
+ if (NESTED_QUANTIFIER.test(trigger))
73
+ return "trigger has nested quantifiers";
74
+ }
75
+ const literal = REGEX_LITERAL.exec(trigger);
76
+ try {
77
+ return literal ? new RegExp(literal[1], literal[2]) : new RegExp(trigger);
78
+ }
79
+ catch (err) {
80
+ return `invalid regex: ${err instanceof Error ? err.message : String(err)}`;
81
+ }
82
+ }
83
+ async function readRule(file, origin) {
84
+ const skip = (reason) => {
85
+ log("WARN", "stream-rules", `Skipping stream rule ${file}: ${reason}`);
86
+ return null;
87
+ };
88
+ let raw;
89
+ try {
90
+ raw = await fs.readFile(file, "utf8");
91
+ }
92
+ catch (err) {
93
+ return skip(`unreadable (${err instanceof Error ? err.message : String(err)})`);
94
+ }
95
+ const { fields, body, hasFrontmatter } = parseFrontmatter(raw);
96
+ if (!hasFrontmatter)
97
+ return skip("missing frontmatter");
98
+ const name = fields.name?.trim();
99
+ if (!name)
100
+ return skip("missing name");
101
+ const trigger = fields.trigger?.trim();
102
+ if (!trigger)
103
+ return skip("missing trigger");
104
+ const scope = parseScope(fields.scope);
105
+ if (!scope)
106
+ return skip(`invalid scope "${fields.scope ?? ""}"`);
107
+ const pattern = compileTrigger(trigger, origin);
108
+ if (typeof pattern === "string")
109
+ return skip(pattern);
110
+ const reminder = body.trim().slice(0, MAX_STREAM_RULE_REMINDER_CHARS);
111
+ if (!reminder)
112
+ return skip("empty reminder body");
113
+ const tools = parseTools(fields.tools);
114
+ return { name, pattern, scope, reminder, ...(tools ? { tools } : {}) };
115
+ }
116
+ async function loadDir(dir, origin) {
117
+ const rules = [];
118
+ for (const file of await listRuleFiles(dir)) {
119
+ const rule = await readRule(file, origin);
120
+ if (rule)
121
+ rules.push(rule);
122
+ }
123
+ return rules;
124
+ }
125
+ /** Load user + project stream rules. Project rules override user rules with the same name. */
126
+ export async function loadStreamRules(cwd, home = os.homedir()) {
127
+ try {
128
+ const byName = new Map();
129
+ for (const rule of await loadDir(path.join(home, ".gg", "rules"), "user")) {
130
+ byName.set(rule.name, rule);
131
+ }
132
+ const projectDir = path.join(cwd, ".gg", "rules");
133
+ // cwd === home would load the same dir twice and apply untrusted limits to user rules.
134
+ if (path.resolve(projectDir) !== path.resolve(home, ".gg", "rules")) {
135
+ for (const rule of await loadDir(projectDir, "project")) {
136
+ byName.delete(rule.name);
137
+ byName.set(rule.name, rule);
138
+ }
139
+ }
140
+ const rules = [...byName.values()];
141
+ if (rules.length > MAX_STREAM_RULES) {
142
+ log("WARN", "stream-rules", `Only the first ${MAX_STREAM_RULES} of ${rules.length} stream rules are used`);
143
+ }
144
+ return rules.slice(0, MAX_STREAM_RULES);
145
+ }
146
+ catch (err) {
147
+ log("WARN", "stream-rules", `Failed to load stream rules: ${err instanceof Error ? err.message : String(err)}`);
148
+ return [];
149
+ }
150
+ }
151
+ //# sourceMappingURL=stream-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream-rules.js","sourceRoot":"","sources":["../../src/core/stream-rules.ts"],"names":[],"mappings":"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"}
@@ -27,6 +27,13 @@ export declare function spotSampleTimes(windows: readonly {
27
27
  start: number;
28
28
  end: number;
29
29
  }[], fps: number, duration: number): number[];
30
+ /**
31
+ * Browser pages `hf check` audits sample times on at once. Each seek mostly waits on a fixed
32
+ * settle, so pages overlap almost perfectly: on a 25 s reel, 4 pages took the audit from 90 s
33
+ * to 29 s for ~1.1 GB more memory (each page holds its own copy of the composition), with the
34
+ * same report. Capped at 4 so a render running alongside keeps its cores.
35
+ */
36
+ export declare function auditPages(cpus: number, memoryBytes: number): number;
30
37
  /** One explicit check; the working agent sees the frames, with no separate model or approval loop. */
31
38
  export declare function createMotionCheckTool(cwd: string, bundle: MotionBundle, limits?: {
32
39
  sourceCheckMs?: number;
@@ -124,16 +124,71 @@ function summarizeFlashes(output) {
124
124
  "most three in any second, shrinking the flashing area below about 3% of the frame, or " +
125
125
  "reducing the brightness difference between the alternating states.");
126
126
  }
127
- /** Source fingerprint for reusing a passing audit; a project it cannot fingerprint is re-audited. */
128
- async function sourceFingerprint(project, output, signal) {
127
+ /**
128
+ * Source fingerprint for reusing a passing audit; a project it cannot fingerprint is re-audited.
129
+ * The hold plan is excluded: the composition never reads it, and the pixel check re-reads it.
130
+ */
131
+ async function sourceFingerprint(project, output, holds, signal) {
129
132
  try {
130
- return await motionSourceHash(project, output, signal);
133
+ return await motionSourceHash(project, output, signal, holds ? [holds] : []);
131
134
  }
132
135
  catch {
133
136
  signal.throwIfAborted();
134
137
  return undefined;
135
138
  }
136
139
  }
140
+ /**
141
+ * Browser pages `hf check` audits sample times on at once. Each seek mostly waits on a fixed
142
+ * settle, so pages overlap almost perfectly: on a 25 s reel, 4 pages took the audit from 90 s
143
+ * to 29 s for ~1.1 GB more memory (each page holds its own copy of the composition), with the
144
+ * same report. Capped at 4 so a render running alongside keeps its cores.
145
+ */
146
+ export function auditPages(cpus, memoryBytes) {
147
+ const byCpu = Math.floor(cpus / 2);
148
+ const byMemory = Math.floor(memoryBytes / 4 / 1024 ** 3);
149
+ return Math.max(1, Math.min(4, byCpu, byMemory));
150
+ }
151
+ /** Frame rate and duration of the export, or undefined when they are missing or out of range. */
152
+ async function probeVideo(ffprobe, output, signal) {
153
+ let stdout;
154
+ try {
155
+ ({ stdout } = await exec(ffprobe, [
156
+ "-v",
157
+ "error",
158
+ "-protocol_whitelist",
159
+ "file,pipe",
160
+ "-select_streams",
161
+ "v:0",
162
+ "-show_entries",
163
+ "stream=avg_frame_rate:format=duration",
164
+ "-of",
165
+ "json",
166
+ output,
167
+ ], {
168
+ signal: AbortSignal.any([signal, AbortSignal.timeout(STEP_MS)]),
169
+ maxBuffer: 64 * 1024,
170
+ windowsHide: true,
171
+ }));
172
+ }
173
+ catch {
174
+ signal.throwIfAborted();
175
+ return undefined;
176
+ }
177
+ let json;
178
+ try {
179
+ json = JSON.parse(stdout);
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ const video = videoSchema.safeParse(json);
185
+ if (!video.success)
186
+ return undefined;
187
+ const [num, den] = video.data.streams[0].avg_frame_rate.split("/").map(Number);
188
+ const fps = Number(num) / Number(den);
189
+ const duration = Number(video.data.format.duration);
190
+ return fps >= 1 && fps <= 120 && duration > 0 && duration <= 3600 ? { fps, duration } : undefined;
191
+ }
137
192
  /**
138
193
  * `hf check --json` prints ~100 KB for a busy video. Give the agent what it acts on:
139
194
  * per-section counts, every error with where and when, warning types, and
@@ -303,32 +358,17 @@ export function createMotionCheckTool(cwd, bundle, limits = {}) {
303
358
  windowsHide: true,
304
359
  });
305
360
  const { ffmpeg, ffprobe } = binariesSchema.parse(JSON.parse(binaries.stdout));
306
- let sampling = ["--at-transitions"];
361
+ const video = await probeVideo(ffprobe, output, signal);
362
+ // Tween boundaries and midpoints land between frames: on a cut-heavy video most are
363
+ // instants no viewer sees. Moving each onto its nearest rendered frame (and merging
364
+ // duplicates) audits only what the export shows: ~460 → ~300 seeks on a 10 s reel.
365
+ // Unreadable metadata keeps the unsnapped audit rather than skipping the check.
366
+ let sampling = ["--at-transitions", ...(video ? [`--frame-rate=${video.fps}`] : [])];
307
367
  let spotFrames;
308
368
  if (input.spot) {
309
- const probe = await exec(ffprobe, [
310
- "-v",
311
- "error",
312
- "-protocol_whitelist",
313
- "file,pipe",
314
- "-select_streams",
315
- "v:0",
316
- "-show_entries",
317
- "stream=avg_frame_rate:format=duration",
318
- "-of",
319
- "json",
320
- output,
321
- ], {
322
- signal: AbortSignal.any([signal, AbortSignal.timeout(STEP_MS)]),
323
- maxBuffer: 64 * 1024,
324
- windowsHide: true,
325
- });
326
- const video = videoSchema.parse(JSON.parse(probe.stdout));
327
- const [num, den] = video.streams[0].avg_frame_rate.split("/").map(Number);
328
- const fps = Number(num) / Number(den);
329
- const duration = Number(video.format.duration);
330
- if (!(fps >= 1 && fps <= 120 && duration > 0 && duration <= 3600))
369
+ if (!video)
331
370
  throw new Error("Unsupported video metadata for a spot check");
371
+ const { fps, duration } = video;
332
372
  const times = spotSampleTimes(input.windows, fps, duration);
333
373
  if (!times.length)
334
374
  throw new Error("Spot windows contain no rendered frames");
@@ -339,9 +379,11 @@ export function createMotionCheckTool(cwd, bundle, limits = {}) {
339
379
  spotFrames = times.length;
340
380
  sampling = ["--at", times.join(",")];
341
381
  }
342
- const fingerprint = input.spot
382
+ // The frame rate decides which instants are audited, so a pass holds only for it.
383
+ const source = input.spot
343
384
  ? undefined
344
- : await sourceFingerprint(project, output, signal);
385
+ : await sourceFingerprint(project, output, holds, signal);
386
+ const fingerprint = source === undefined ? undefined : `${source}@${video?.fps ?? 0}`;
345
387
  const passed = passedSources.get(project);
346
388
  let runtime;
347
389
  if (fingerprint !== undefined && passed?.fingerprint === fingerprint) {
@@ -354,12 +396,20 @@ export function createMotionCheckTool(cwd, bundle, limits = {}) {
354
396
  }
355
397
  else {
356
398
  // HyperFrames check includes lint; running `hf lint` separately duplicates that work.
357
- runtime = await run(SOURCE_CHECK, process.execPath, [bundle.launcher, "check", project, "--json", "--contrast", ...sampling], { timeoutMs: sourceCheckMs, describe: summarizeSourceCheck });
399
+ runtime = await run(SOURCE_CHECK, process.execPath, [
400
+ bundle.launcher,
401
+ "check",
402
+ project,
403
+ "--json",
404
+ "--contrast",
405
+ ...sampling,
406
+ `--workers=${auditPages(os.availableParallelism(), os.totalmem())}`,
407
+ ], { timeoutMs: sourceCheckMs, describe: summarizeSourceCheck });
358
408
  // Keep a pass only if the source did not change while it was being audited.
359
409
  if (fingerprint !== undefined &&
360
410
  runtime !== null &&
361
411
  isMotionTechnicalReport(runtime.stdout) &&
362
- (await sourceFingerprint(project, output, signal)) === fingerprint)
412
+ (await sourceFingerprint(project, output, holds, signal)) === source)
363
413
  passedSources.set(project, { fingerprint, result: runtime });
364
414
  }
365
415
  validate("Structured runtime report", runtime !== null && isMotionTechnicalReport(runtime.stdout));