oh-my-opencode 4.16.0 → 4.16.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.
- package/.agents/skills/codex-qa/SKILL.md +9 -7
- package/.agents/skills/codex-qa/references/logging-debug.md +6 -4
- package/.agents/skills/opencode-qa/SKILL.md +9 -9
- package/.agents/skills/opencode-qa/references/tui-tmux.md +10 -8
- package/dist/cli/codex-ulw-loop.d.ts +8 -0
- package/dist/cli/get-local-version/types.d.ts +1 -1
- package/dist/cli/index.js +260 -207
- package/dist/cli-node/index.js +260 -207
- package/dist/features/builtin-commands/templates/start-work.d.ts +1 -1
- package/dist/features/monitor/process.d.ts +17 -1
- package/dist/index.js +1048 -1001
- package/dist/skills/frontend/SKILL.md +9 -9
- package/dist/skills/frontend/references/design/README.md +7 -3
- package/dist/skills/frontend/references/design/_INDEX.md +1 -1
- package/dist/skills/frontend/references/design/design-system-architecture.md +24 -2
- package/dist/skills/frontend/references/designpowers/README.md +2 -2
- package/dist/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
- package/dist/skills/start-work/SKILL.md +2 -2
- package/dist/skills/visual-qa/SKILL.md +13 -17
- package/docs/reference/web-terminal-visual-qa.md +39 -45
- package/package.json +31 -25
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +18 -10
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +18 -10
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +11 -0
- package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -1
- package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +4 -0
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +18 -10
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -2
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-steering.js +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -27
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +6 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +55 -9
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.d.ts +15 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.js +33 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-types.d.ts +10 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +15 -11
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +7 -7
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +17 -2
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +59 -11
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering-snapshot.ts +38 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering-types.ts +11 -3
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +15 -7
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +16 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +260 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/steering-snapshot.test.ts +124 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +101 -2
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +13 -13
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +10 -6
- package/packages/omo-codex/plugin/skills/frontend/SKILL.md +9 -9
- package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +7 -3
- package/packages/omo-codex/plugin/skills/frontend/references/design/_INDEX.md +1 -1
- package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +24 -2
- package/packages/omo-codex/plugin/skills/frontend/references/designpowers/README.md +2 -2
- package/packages/omo-codex/plugin/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -2
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +18 -10
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +7 -7
- package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +11 -0
- package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +13 -17
- package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +4 -4
- package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +1 -1
- package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +22 -9
- package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
- package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +52 -0
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +65 -39
- package/packages/omo-codex/scripts/install-lazycodex-version-stamp.test.mjs +2 -2
- package/packages/shared-skills/skills/frontend/SKILL.md +9 -9
- package/packages/shared-skills/skills/frontend/references/design/README.md +7 -3
- package/packages/shared-skills/skills/frontend/references/design/_INDEX.md +1 -1
- package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +24 -2
- package/packages/shared-skills/skills/frontend/references/designpowers/README.md +2 -2
- package/packages/shared-skills/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
- package/packages/shared-skills/skills/start-work/SKILL.md +2 -2
- package/packages/shared-skills/skills/visual-qa/SKILL.md +13 -17
- package/script/qa/strip-ansi.mjs +10 -0
- package/script/qa/web-terminal-visual-qa.mjs +112 -195
- package/script/qa/xterm-live-terminal.mjs +180 -0
- package/script/qa/web-terminal-renderer.mjs +0 -218
|
@@ -1,84 +1,64 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
// Render terminal/TUI evidence through a REAL xterm.js terminal in a browser.
|
|
3
|
+
//
|
|
4
|
+
// A command runs in a real pty (node-pty), streams into xterm.js inside headless
|
|
5
|
+
// Chrome, is driven with scripted keystrokes THROUGH the browser terminal, and is
|
|
6
|
+
// screenshotted true-color. This replaces the old tmux capture-pane + hand-rolled
|
|
7
|
+
// ANSI-to-HTML path, which degraded color and never rendered a real terminal.
|
|
8
|
+
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
11
|
import { basename, join, resolve } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
escapeHtml,
|
|
12
|
-
renderAnsiToHtml,
|
|
13
|
-
stripAnsi,
|
|
14
|
-
} from "./web-terminal-renderer.mjs";
|
|
15
|
-
import {
|
|
16
|
-
BUILT_IN_REDACTION_RULE_COUNT,
|
|
17
|
-
compileRedactions,
|
|
18
|
-
redactEvidence,
|
|
19
|
-
} from "./web-terminal-redaction.mjs";
|
|
12
|
+
import { captureLive } from "./xterm-live-terminal.mjs";
|
|
13
|
+
import { BUILT_IN_REDACTION_RULE_COUNT, compileRedactions, redactEvidence } from "./web-terminal-redaction.mjs";
|
|
14
|
+
import { stripAnsi } from "./strip-ansi.mjs";
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
20
17
|
|
|
21
18
|
const HELP = `web-terminal-visual-qa
|
|
22
19
|
|
|
23
|
-
Render terminal/TUI evidence through a browser
|
|
20
|
+
Render terminal/TUI evidence through a REAL xterm.js terminal captured in a browser (true color, no tmux).
|
|
24
21
|
|
|
25
22
|
Usage:
|
|
26
|
-
node script/qa/web-terminal-visual-qa.mjs --title "Codex TUI" --
|
|
27
|
-
node script/qa/web-terminal-visual-qa.mjs --title "
|
|
23
|
+
node script/qa/web-terminal-visual-qa.mjs --title "Codex TUI" --command "codex --help" --evidence-dir .omo/evidence/run
|
|
24
|
+
node script/qa/web-terminal-visual-qa.mjs --title "TUI" --command "my-tui" --input "{Down}" --input "{Down}" --input "{Enter}" --evidence-dir .omo/evidence/run
|
|
25
|
+
node script/qa/web-terminal-visual-qa.mjs --title "Replay" --from-file pane.ansi --evidence-dir .omo/evidence/run
|
|
26
|
+
node script/qa/web-terminal-visual-qa.mjs --self-test
|
|
28
27
|
|
|
29
28
|
Inputs:
|
|
30
|
-
--
|
|
31
|
-
--
|
|
32
|
-
--
|
|
33
|
-
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--evidence-dir <path> Directory for terminal.
|
|
39
|
-
--chrome-bin <path> Chrome/Chromium executable
|
|
29
|
+
--command <command> Run in a real node-pty and render live in xterm.js. The color path is xterm.js - NEVER tmux.
|
|
30
|
+
--from-file <path> Render an existing raw terminal byte stream through xterm.js (replay; no interaction).
|
|
31
|
+
--input <token> Scripted interaction, repeatable, applied in order THROUGH the browser terminal.
|
|
32
|
+
Literal text is typed; {Enter} {Tab} {Escape} {ArrowDown} {Ctrl+C} etc. are pressed as keys.
|
|
33
|
+
--cwd <path> Working directory for --command. Default: current directory.
|
|
34
|
+
--cols <n> / --rows <n> Terminal geometry. Default: 120 x 32.
|
|
35
|
+
--dwell-ms <n> Milliseconds to let the TUI settle after input before capture. Default: 1500.
|
|
36
|
+
--key-delay-ms <n> Pause between --input tokens. Default: 120.
|
|
37
|
+
--evidence-dir <path> Directory for terminal.png, terminal.txt, terminal-ansi.txt, metadata.json.
|
|
38
|
+
--chrome-bin <path> Chrome/Chromium executable (else auto-detect or CHROME_BIN).
|
|
40
39
|
--source-label <text> Safe label for --command metadata. The raw command is never written to metadata.
|
|
41
|
-
--redact <literal> Literal secret
|
|
42
|
-
--redact-regex <expr>
|
|
43
|
-
--no-browser Skip
|
|
44
|
-
|
|
45
|
-
Connector notes:
|
|
46
|
-
--command uses tmux as the tmux-backed PTY connector on macOS/Linux and on Windows environments that provide tmux.
|
|
47
|
-
Windows-native ConPTY live mode should plug into this same metadata contract later; until then use --from-file or Git Bash/tmux.
|
|
40
|
+
--redact <literal> Literal secret to mask in ALL evidence, PNG included. Repeatable.
|
|
41
|
+
--redact-regex <expr> JS regex source to mask in ALL evidence, PNG included. Repeatable.
|
|
42
|
+
--no-browser Skip xterm.js/Chrome; capture the raw pty stream only (no PNG). For chrome-less CI.
|
|
48
43
|
|
|
49
44
|
Secret handling:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Add --redact for exact local values and --redact-regex for project-specific patterns.
|
|
53
|
-
The raw --command string is treated as secret-bearing process data and is never stored in metadata.json.
|
|
45
|
+
Text evidence and the screenshot are redacted before anything is written. When a redaction rule matches, the
|
|
46
|
+
masked stream is re-rendered so the PNG never shows the secret. The raw --command string is never stored.
|
|
54
47
|
`;
|
|
55
48
|
|
|
49
|
+
function parsePositiveInt(name, value) {
|
|
50
|
+
const parsed = Number.parseInt(value, 10);
|
|
51
|
+
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
56
55
|
function parseArgs(argv) {
|
|
57
|
-
const args = {
|
|
58
|
-
cols: 140,
|
|
59
|
-
rows: 40,
|
|
60
|
-
dwellMs: 3000,
|
|
61
|
-
cwd: process.cwd(),
|
|
62
|
-
browser: true,
|
|
63
|
-
wrap: true,
|
|
64
|
-
redactions: [],
|
|
65
|
-
redactRegexes: [],
|
|
66
|
-
};
|
|
56
|
+
const args = { cols: 120, rows: 32, dwellMs: 1500, keyDelayMs: 120, cwd: process.cwd(), browser: true, redactions: [], redactRegexes: [], inputs: [] };
|
|
67
57
|
for (let i = 0; i < argv.length; i += 1) {
|
|
68
58
|
const arg = argv[i];
|
|
69
59
|
if (arg === "--help" || arg === "-h") return { ...args, help: true };
|
|
70
|
-
if (arg === "--
|
|
71
|
-
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (arg === "--wrap") {
|
|
75
|
-
args.wrap = true;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (arg === "--no-wrap") {
|
|
79
|
-
args.wrap = false;
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
60
|
+
if (arg === "--self-test") return { ...args, selfTest: true };
|
|
61
|
+
if (arg === "--no-browser") { args.browser = false; continue; }
|
|
82
62
|
const next = argv[i + 1];
|
|
83
63
|
if (!next) throw new Error(`missing value for ${arg}`);
|
|
84
64
|
i += 1;
|
|
@@ -89,22 +69,18 @@ function parseArgs(argv) {
|
|
|
89
69
|
else if (arg === "--evidence-dir") args.evidenceDir = next;
|
|
90
70
|
else if (arg === "--chrome-bin") args.chromeBin = next;
|
|
91
71
|
else if (arg === "--source-label") args.sourceLabel = next;
|
|
72
|
+
else if (arg === "--input") args.inputs.push(next);
|
|
92
73
|
else if (arg === "--redact") args.redactions.push(next);
|
|
93
74
|
else if (arg === "--redact-regex") args.redactRegexes.push(next);
|
|
94
75
|
else if (arg === "--cols") args.cols = parsePositiveInt(arg, next);
|
|
95
76
|
else if (arg === "--rows") args.rows = parsePositiveInt(arg, next);
|
|
96
77
|
else if (arg === "--dwell-ms") args.dwellMs = parsePositiveInt(arg, next);
|
|
78
|
+
else if (arg === "--key-delay-ms") args.keyDelayMs = parsePositiveInt(arg, next);
|
|
97
79
|
else throw new Error(`unknown argument: ${arg}`);
|
|
98
80
|
}
|
|
99
81
|
return args;
|
|
100
82
|
}
|
|
101
83
|
|
|
102
|
-
function parsePositiveInt(name, value) {
|
|
103
|
-
const parsed = Number.parseInt(value, 10);
|
|
104
|
-
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
|
|
105
|
-
return parsed;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
84
|
function requireArgs(args) {
|
|
109
85
|
if (!args.evidenceDir) throw new Error("--evidence-dir is required");
|
|
110
86
|
if (!args.title) throw new Error("--title is required");
|
|
@@ -112,153 +88,94 @@ function requireArgs(args) {
|
|
|
112
88
|
if (!args.fromFile && !args.command) throw new Error("choose --from-file or --command");
|
|
113
89
|
}
|
|
114
90
|
|
|
115
|
-
function terminalWidthCh(cols) {
|
|
116
|
-
return Math.max(80, Math.min(cols, 160));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function screenshotSize({ cols, rows }) {
|
|
120
|
-
return {
|
|
121
|
-
width: Math.max(900, Math.min(1440, Math.round(cols * 8.2 + 120))),
|
|
122
|
-
height: Math.max(520, Math.min(1200, Math.round(rows * 18 + 120))),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
91
|
function sourceMetadata(args) {
|
|
127
92
|
if (args.fromFile) return { kind: "file-replay", path: resolve(args.fromFile) };
|
|
128
93
|
return { kind: "command", label: args.sourceLabel || "redacted command" };
|
|
129
94
|
}
|
|
130
95
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.ansi-dim { opacity: 0.72; }
|
|
151
|
-
.ansi-italic { font-style: italic; }
|
|
152
|
-
.ansi-underline { text-decoration: underline; }
|
|
153
|
-
.ansi-strike { text-decoration: line-through; }
|
|
154
|
-
${ansiColorCss()}
|
|
155
|
-
</style>
|
|
156
|
-
</head>
|
|
157
|
-
<body><main><section class="terminal"><div class="bar"><span class="dot"></span><strong>${escapeHtml(title)}</strong></div><pre>${renderAnsiToHtml(ansi)}</pre></section></main></body>
|
|
158
|
-
</html>
|
|
159
|
-
`;
|
|
160
|
-
writeFileSync(outPath, html, "utf8");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function captureFromFile(path) {
|
|
164
|
-
const ansi = readFileSync(path, "utf8");
|
|
165
|
-
return { ansi, cleanup: "cleanup: file replay; no live process", connector: "file-replay" };
|
|
96
|
+
// Chrome-less path: run the command in a real pty and keep the raw stream only.
|
|
97
|
+
async function captureRawPty(args) {
|
|
98
|
+
const { chmodSync, existsSync: exists } = await import("node:fs");
|
|
99
|
+
const { dirname, join: pjoin } = await import("node:path");
|
|
100
|
+
try {
|
|
101
|
+
const ptyRoot = dirname(require.resolve("node-pty"));
|
|
102
|
+
const helper = pjoin(ptyRoot, `../prebuilds/${process.platform}-${process.arch}/spawn-helper`);
|
|
103
|
+
if (exists(helper)) chmodSync(helper, 0o755);
|
|
104
|
+
} catch {}
|
|
105
|
+
const pty = require("node-pty");
|
|
106
|
+
const proc = pty.spawn(process.env.SHELL || "bash", ["-lc", args.command], {
|
|
107
|
+
name: "xterm-256color", cols: args.cols, rows: args.rows, cwd: args.cwd,
|
|
108
|
+
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
|
|
109
|
+
});
|
|
110
|
+
let raw = "";
|
|
111
|
+
proc.onData((d) => { raw += d; });
|
|
112
|
+
await new Promise((r) => setTimeout(r, args.dwellMs + 400));
|
|
113
|
+
try { proc.kill(); } catch {}
|
|
114
|
+
return { pngBuffer: null, screenText: stripAnsi(raw), rawStream: raw, connector: "node-pty-raw", cleanup: `pty pid ${proc.pid} killed` };
|
|
166
115
|
}
|
|
167
116
|
|
|
168
|
-
function
|
|
169
|
-
return
|
|
117
|
+
function captureFileRaw(content) {
|
|
118
|
+
return { pngBuffer: null, screenText: stripAnsi(content), rawStream: content, connector: "file-raw", cleanup: "file replay; no process" };
|
|
170
119
|
}
|
|
171
120
|
|
|
172
|
-
function
|
|
173
|
-
const tmux = spawnSync("tmux", ["-V"], { encoding: "utf8" });
|
|
174
|
-
if (tmux.status !== 0) throw new Error("--command requires tmux on PATH; use --from-file on hosts without tmux");
|
|
175
|
-
const session = `omo_webterm_${process.pid}_${Date.now()}`;
|
|
176
|
-
const wrapped = `${args.command}; printf '\\n[web-terminal-visual-qa exit:%s]\\n' "$?"; sleep 600`;
|
|
177
|
-
const launch = spawnSync("tmux", ["new-session", "-d", "-s", session, "-x", String(args.cols), "-y", String(args.rows), "-c", resolve(args.cwd), "sh", "-lc", wrapped], { encoding: "utf8" });
|
|
178
|
-
if (launch.status !== 0) throw new Error(`tmux launch failed: ${launch.stderr || launch.stdout}`);
|
|
179
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, args.dwellMs);
|
|
180
|
-
const plain = spawnSync("tmux", ["capture-pane", "-p", "-S", "-", "-t", session], { encoding: "utf8" });
|
|
181
|
-
const ansi = spawnSync("tmux", ["capture-pane", "-e", "-p", "-S", "-", "-t", session], { encoding: "utf8" });
|
|
182
|
-
spawnSync("tmux", ["kill-session", "-t", session], { encoding: "utf8" });
|
|
183
|
-
const plainCapture = plain.status === 0 ? plain.stdout : "";
|
|
184
|
-
const ansiCapture = ansi.status === 0 ? ansi.stdout : "";
|
|
185
|
-
const captured = ansiCapture || plainCapture;
|
|
186
|
-
if (!captured || stripAnsi(captured).trim().length === 0) {
|
|
187
|
-
const detail = [plain.stderr || plain.stdout, ansi.stderr || ansi.stdout].filter(Boolean).join("; ");
|
|
188
|
-
throw new Error(`tmux capture was empty${detail ? `: ${detail}` : ""}`);
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
ansi: captured,
|
|
192
|
-
cleanup: `cleanup: tmux kill-session -t ${shellQuote(session)}`,
|
|
193
|
-
connector: "tmux-backed-pty",
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function chromeCandidates(explicit) {
|
|
198
|
-
const candidates = [explicit, process.env.CHROME_BIN, process.env.GOOGLE_CHROME_BIN];
|
|
199
|
-
if (process.platform === "darwin") candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium");
|
|
200
|
-
if (process.platform === "linux") candidates.push("google-chrome", "google-chrome-stable", "chromium", "chromium-browser");
|
|
201
|
-
if (process.platform === "win32") {
|
|
202
|
-
candidates.push(join(process.env.PROGRAMFILES || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"));
|
|
203
|
-
candidates.push(join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"));
|
|
204
|
-
}
|
|
205
|
-
return candidates.filter((candidate) => candidate && (candidate.includes("/") || candidate.includes("\\") ? existsSync(candidate) : true));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function capturePng(args) {
|
|
209
|
-
for (const chrome of chromeCandidates(args.chromeBin)) {
|
|
210
|
-
const result = spawnSync(chrome, ["--headless=new", "--disable-gpu", `--window-size=${args.width},${args.height}`, `--screenshot=${args.pngPath}`, pathToFileURL(args.htmlPath).href], { encoding: "utf8" });
|
|
211
|
-
if (result.status === 0 && existsSync(args.pngPath)) return { status: "captured", chrome };
|
|
212
|
-
}
|
|
213
|
-
throw new Error("PNG capture failed: set --chrome-bin or --no-browser");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function main() {
|
|
217
|
-
const args = parseArgs(process.argv.slice(2));
|
|
218
|
-
if (args.help) {
|
|
219
|
-
process.stdout.write(HELP);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
requireArgs(args);
|
|
121
|
+
async function run(args) {
|
|
223
122
|
const evidenceDir = resolve(args.evidenceDir);
|
|
224
123
|
mkdirSync(evidenceDir, { recursive: true });
|
|
225
|
-
const
|
|
124
|
+
const rules = compileRedactions(args);
|
|
125
|
+
const redactStream = (s) => redactEvidence(s, rules);
|
|
126
|
+
const fromFile = args.fromFile ? readFileSync(args.fromFile, "utf8") : undefined;
|
|
127
|
+
|
|
128
|
+
let cap;
|
|
129
|
+
if (args.browser) cap = await captureLive({ ...args, fromFile, redactStream });
|
|
130
|
+
else if (fromFile !== undefined) cap = captureFileRaw(fromFile);
|
|
131
|
+
else cap = await captureRawPty(args);
|
|
132
|
+
|
|
133
|
+
const safeText = redactStream(cap.screenText);
|
|
134
|
+
const safeAnsi = redactStream(cap.rawStream);
|
|
226
135
|
const textPath = join(evidenceDir, "terminal.txt");
|
|
227
136
|
const ansiPath = join(evidenceDir, "terminal-ansi.txt");
|
|
228
137
|
const pngPath = join(evidenceDir, "terminal.png");
|
|
229
138
|
const metadataPath = join(evidenceDir, "metadata.json");
|
|
230
|
-
|
|
231
|
-
const redactionRules = compileRedactions(args);
|
|
232
|
-
const safeAnsi = redactEvidence(capture.ansi, redactionRules);
|
|
233
|
-
const text = stripAnsi(safeAnsi);
|
|
234
|
-
writeFileSync(textPath, text, "utf8");
|
|
139
|
+
writeFileSync(textPath, safeText.endsWith("\n") ? safeText : `${safeText}\n`, "utf8");
|
|
235
140
|
writeFileSync(ansiPath, safeAnsi, "utf8");
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const browser = args.browser ? capturePng({ chromeBin: args.chromeBin, htmlPath, pngPath, ...size }) : { status: "skipped" };
|
|
141
|
+
if (cap.pngBuffer) writeFileSync(pngPath, cap.pngBuffer);
|
|
142
|
+
|
|
239
143
|
const metadata = {
|
|
240
144
|
title: args.title,
|
|
241
|
-
connector:
|
|
242
|
-
|
|
145
|
+
connector: cap.connector,
|
|
146
|
+
colorPath: "xterm.js (true color; not tmux)",
|
|
147
|
+
browserCapture: cap.pngBuffer ? "captured" : "skipped",
|
|
243
148
|
source: sourceMetadata(args),
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
},
|
|
249
|
-
wrap: args.wrap ? "on" : "off",
|
|
250
|
-
dimensions: { cols: args.cols, rows: args.rows, screenshotWidth: size.width, screenshotHeight: size.height },
|
|
251
|
-
cleanup: capture.cleanup,
|
|
252
|
-
files: { html: htmlPath, text: textPath, ansi: ansiPath, png: browser.status === "captured" ? pngPath : null, metadata: metadataPath },
|
|
149
|
+
interaction: args.inputs,
|
|
150
|
+
redaction: { builtInRules: BUILT_IN_REDACTION_RULE_COUNT, literalRules: args.redactions.length, regexRules: args.redactRegexes.length },
|
|
151
|
+
dimensions: { cols: args.cols, rows: args.rows },
|
|
152
|
+
cleanup: cap.cleanup,
|
|
153
|
+
files: { png: cap.pngBuffer ? pngPath : null, text: textPath, ansi: ansiPath, metadata: metadataPath },
|
|
253
154
|
};
|
|
254
155
|
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
255
|
-
process.stdout.write(`web terminal visual QA evidence (${basename(evidenceDir)}):\n${JSON.stringify(metadata.files, null, 2)}\
|
|
156
|
+
process.stdout.write(`web terminal visual QA evidence (${basename(evidenceDir)}):\n${JSON.stringify(metadata.files, null, 2)}\ncleanup: ${cap.cleanup}\n`);
|
|
256
157
|
}
|
|
257
158
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
process.
|
|
159
|
+
async function selfTest() {
|
|
160
|
+
// Asset resolution + real pty capture, without requiring Chrome (chrome-less CI safe).
|
|
161
|
+
for (const spec of ["@xterm/xterm/lib/xterm.js", "@xterm/xterm/css/xterm.css", "@xterm/addon-unicode11/lib/addon-unicode11.js"]) {
|
|
162
|
+
if (readFileSync(require.resolve(spec), "utf8").length < 100) throw new Error(`asset too small: ${spec}`);
|
|
163
|
+
}
|
|
164
|
+
const cap = await captureRawPty({ command: "printf '\\033[31mRED\\033[0m \\033[32mGREEN\\033[0m 한글ABC'", cwd: process.cwd(), cols: 40, rows: 8, dwellMs: 300 });
|
|
165
|
+
if (!/RED/.test(cap.rawStream) || !cap.rawStream.includes("[31m")) throw new Error("pty did not emit expected ANSI");
|
|
166
|
+
if (!cap.rawStream.includes("한글")) throw new Error("pty dropped CJK bytes");
|
|
167
|
+
process.stdout.write("self-test PASS: xterm assets resolve; node-pty emits true-color ANSI + CJK\n");
|
|
264
168
|
}
|
|
169
|
+
|
|
170
|
+
async function main() {
|
|
171
|
+
const args = parseArgs(process.argv.slice(2));
|
|
172
|
+
if (args.help) { process.stdout.write(HELP); return; }
|
|
173
|
+
if (args.selfTest) { await selfTest(); return; }
|
|
174
|
+
requireArgs(args);
|
|
175
|
+
await run(args);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
main().catch((error) => {
|
|
179
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Live xterm.js + node-pty terminal capture core.
|
|
2
|
+
//
|
|
3
|
+
// Spawns a command in a REAL pty (node-pty), bridges it to a REAL xterm.js
|
|
4
|
+
// terminal running in a headless Chrome page (puppeteer-core drives the system
|
|
5
|
+
// Chrome), drives scripted interaction THROUGH the browser terminal, then
|
|
6
|
+
// screenshots the xterm.js render. The screenshot is true-color because
|
|
7
|
+
// xterm.js interprets the raw pty byte stream itself - no tmux capture-pane,
|
|
8
|
+
// no hand-rolled ANSI-to-HTML, no color degradation.
|
|
9
|
+
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { chmodSync, existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
function resolveAsset(spec) {
|
|
17
|
+
const path = require.resolve(spec);
|
|
18
|
+
return readFileSync(path, "utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// node-pty ships a `spawn-helper` on macOS/Linux that MUST be executable.
|
|
22
|
+
// `bun install` (unlike npm) does not run node-pty's chmod postinstall, so the
|
|
23
|
+
// committed harness heals the exec bit itself before the first spawn.
|
|
24
|
+
function healSpawnHelper() {
|
|
25
|
+
let ptyRoot;
|
|
26
|
+
try {
|
|
27
|
+
ptyRoot = dirname(require.resolve("node-pty"));
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const candidates = [
|
|
32
|
+
join(ptyRoot, `../prebuilds/${process.platform}-${process.arch}/spawn-helper`),
|
|
33
|
+
join(ptyRoot, "../build/Release/spawn-helper"),
|
|
34
|
+
];
|
|
35
|
+
for (const helper of candidates) {
|
|
36
|
+
if (existsSync(helper)) {
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(helper, 0o755);
|
|
39
|
+
} catch {
|
|
40
|
+
// best effort - a read-only store still works when the bit is already set
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildPageHtml({ xtermJs, xtermCss, unicodeJs, cols, rows, fontSize }) {
|
|
47
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
48
|
+
${xtermCss}
|
|
49
|
+
html,body{margin:0;padding:0;background:#0b0e14}
|
|
50
|
+
#t{padding:8px}
|
|
51
|
+
</style></head><body><div id="t"></div>
|
|
52
|
+
<script>${xtermJs}</script>
|
|
53
|
+
<script>${unicodeJs}</script>
|
|
54
|
+
<script>
|
|
55
|
+
const term = new Terminal({
|
|
56
|
+
cols: ${cols}, rows: ${rows}, fontSize: ${fontSize},
|
|
57
|
+
fontFamily: 'Menlo, "DejaVu Sans Mono", "Noto Sans Mono CJK KR", monospace',
|
|
58
|
+
allowProposedApi: true, convertEol: false, scrollback: 0,
|
|
59
|
+
theme: { background: '#0b0e14', foreground: '#d7dae0' },
|
|
60
|
+
});
|
|
61
|
+
try { const u = new Unicode11Addon.Unicode11Addon(); term.loadAddon(u); term.unicode.activeVersion = '11'; } catch (e) {}
|
|
62
|
+
term.open(document.getElementById('t'));
|
|
63
|
+
window.__writeToTerm = (d) => term.write(d);
|
|
64
|
+
window.__screenText = () => {
|
|
65
|
+
const b = term.buffer.active; const lines = [];
|
|
66
|
+
for (let i = 0; i < b.length; i++) { const ln = b.getLine(i); lines.push(ln ? ln.translateToString(true) : ''); }
|
|
67
|
+
return lines.join('\\n').replace(/\\n+$/, '\\n');
|
|
68
|
+
};
|
|
69
|
+
window.__resetAndWrite = (d) => { term.reset(); term.write(d); };
|
|
70
|
+
term.focus();
|
|
71
|
+
term.onData((d) => { if (window.__ptyInput) window.__ptyInput(d); });
|
|
72
|
+
</script></body></html>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const NAMED_KEYS = new Set([
|
|
76
|
+
"Enter", "Tab", "Escape", "Backspace", "Delete", "Space",
|
|
77
|
+
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// An `input` token wrapped in {Braces} is pressed as a named key; anything else
|
|
81
|
+
// is typed literally. Both flow through the browser terminal (xterm onData ->
|
|
82
|
+
// pty), so the interaction is genuinely driven in the web terminal.
|
|
83
|
+
async function driveInput(page, inputs, keyDelayMs) {
|
|
84
|
+
for (const raw of inputs) {
|
|
85
|
+
const match = /^\{(.+)\}$/.exec(raw);
|
|
86
|
+
if (match && NAMED_KEYS.has(match[1])) {
|
|
87
|
+
await page.keyboard.press(match[1] === "Space" ? " " : match[1], { delay: 15 });
|
|
88
|
+
} else if (match && /^Ctrl\+(.)$/i.test(match[1])) {
|
|
89
|
+
const key = /^Ctrl\+(.)$/i.exec(match[1])[1];
|
|
90
|
+
await page.keyboard.down("Control");
|
|
91
|
+
await page.keyboard.press(key);
|
|
92
|
+
await page.keyboard.up("Control");
|
|
93
|
+
} else {
|
|
94
|
+
await page.keyboard.type(raw, { delay: 10 });
|
|
95
|
+
}
|
|
96
|
+
if (keyDelayMs > 0) await new Promise((r) => setTimeout(r, keyDelayMs));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function chromeCandidates(explicit) {
|
|
101
|
+
const c = [explicit, process.env.CHROME_BIN, process.env.GOOGLE_CHROME_BIN];
|
|
102
|
+
if (process.platform === "darwin")
|
|
103
|
+
c.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium");
|
|
104
|
+
if (process.platform === "linux") c.push("/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser");
|
|
105
|
+
if (process.platform === "win32") {
|
|
106
|
+
c.push(join(process.env.PROGRAMFILES || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"));
|
|
107
|
+
c.push(join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"));
|
|
108
|
+
}
|
|
109
|
+
return c.filter((x) => x && (x.includes("/") || x.includes("\\") ? existsSync(x) : true));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function captureLive({ command, cwd, cols, rows, inputs, dwellMs, keyDelayMs, chromeBin, fromFile, redactStream }) {
|
|
113
|
+
healSpawnHelper();
|
|
114
|
+
const puppeteer = (await import("puppeteer-core")).default;
|
|
115
|
+
const executablePath = chromeCandidates(chromeBin)[0];
|
|
116
|
+
if (!executablePath) throw new Error("no Chrome/Chromium found; set --chrome-bin or CHROME_BIN");
|
|
117
|
+
|
|
118
|
+
const html = buildPageHtml({
|
|
119
|
+
xtermJs: resolveAsset("@xterm/xterm/lib/xterm.js"),
|
|
120
|
+
xtermCss: resolveAsset("@xterm/xterm/css/xterm.css"),
|
|
121
|
+
unicodeJs: resolveAsset("@xterm/addon-unicode11/lib/addon-unicode11.js"),
|
|
122
|
+
cols, rows, fontSize: 15,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const browser = await puppeteer.launch({
|
|
126
|
+
executablePath, headless: "new",
|
|
127
|
+
args: ["--no-sandbox", "--disable-gpu", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
128
|
+
defaultViewport: { width: cols * 10 + 40, height: rows * 20 + 40, deviceScaleFactor: 2 },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let rawStream = "";
|
|
132
|
+
let ptyProc;
|
|
133
|
+
const cleanupParts = [];
|
|
134
|
+
try {
|
|
135
|
+
const page = await browser.newPage();
|
|
136
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
137
|
+
await page.evaluate(() => document.fonts && document.fonts.ready);
|
|
138
|
+
|
|
139
|
+
if (fromFile) {
|
|
140
|
+
rawStream = fromFile;
|
|
141
|
+
const shown = redactStream ? redactStream(rawStream) : rawStream;
|
|
142
|
+
await page.evaluate((d) => window.__writeToTerm(d), shown);
|
|
143
|
+
cleanupParts.push("no pty (replay)");
|
|
144
|
+
} else {
|
|
145
|
+
const pty = require("node-pty");
|
|
146
|
+
ptyProc = pty.spawn(process.env.SHELL || "bash", ["-lc", command], {
|
|
147
|
+
name: "xterm-256color", cols, rows, cwd: cwd || process.cwd(),
|
|
148
|
+
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
|
|
149
|
+
});
|
|
150
|
+
await page.exposeFunction("__ptyInput", (d) => { try { ptyProc.write(d); } catch {} });
|
|
151
|
+
ptyProc.onData((d) => {
|
|
152
|
+
rawStream += d;
|
|
153
|
+
page.evaluate((chunk) => window.__writeToTerm(chunk), d).catch(() => {});
|
|
154
|
+
});
|
|
155
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
156
|
+
await page.focus("#t");
|
|
157
|
+
if (inputs.length) await driveInput(page, inputs, keyDelayMs);
|
|
158
|
+
await new Promise((r) => setTimeout(r, dwellMs));
|
|
159
|
+
cleanupParts.push(`pty pid ${ptyProc.pid} killed`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// When redactions are configured, re-render the masked stream so the PNG
|
|
163
|
+
// never shows a secret that the interaction surfaced on screen.
|
|
164
|
+
if (redactStream && !fromFile) {
|
|
165
|
+
const masked = redactStream(rawStream);
|
|
166
|
+
if (masked !== rawStream) await page.evaluate((d) => window.__resetAndWrite(d), masked);
|
|
167
|
+
}
|
|
168
|
+
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
169
|
+
|
|
170
|
+
const screenText = await page.evaluate(() => window.__screenText());
|
|
171
|
+
const el = (await page.$(".xterm")) || page;
|
|
172
|
+
const pngBuffer = await el.screenshot({ type: "png" });
|
|
173
|
+
return { pngBuffer, screenText, rawStream, connector: fromFile ? "xterm-replay" : "xterm-node-pty", cleanup: cleanupParts.join("; ") };
|
|
174
|
+
} finally {
|
|
175
|
+
try { ptyProc && ptyProc.kill(); } catch {}
|
|
176
|
+
await browser.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { captureLive };
|