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.
Files changed (128) hide show
  1. package/.agents/skills/codex-qa/SKILL.md +9 -7
  2. package/.agents/skills/codex-qa/references/logging-debug.md +6 -4
  3. package/.agents/skills/opencode-qa/SKILL.md +9 -9
  4. package/.agents/skills/opencode-qa/references/tui-tmux.md +10 -8
  5. package/dist/cli/codex-ulw-loop.d.ts +8 -0
  6. package/dist/cli/get-local-version/types.d.ts +1 -1
  7. package/dist/cli/index.js +260 -207
  8. package/dist/cli-node/index.js +260 -207
  9. package/dist/features/builtin-commands/templates/start-work.d.ts +1 -1
  10. package/dist/features/monitor/process.d.ts +17 -1
  11. package/dist/index.js +1048 -1001
  12. package/dist/skills/frontend/SKILL.md +9 -9
  13. package/dist/skills/frontend/references/design/README.md +7 -3
  14. package/dist/skills/frontend/references/design/_INDEX.md +1 -1
  15. package/dist/skills/frontend/references/design/design-system-architecture.md +24 -2
  16. package/dist/skills/frontend/references/designpowers/README.md +2 -2
  17. package/dist/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  18. package/dist/skills/start-work/SKILL.md +2 -2
  19. package/dist/skills/visual-qa/SKILL.md +13 -17
  20. package/docs/reference/web-terminal-visual-qa.md +39 -45
  21. package/package.json +31 -25
  22. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  23. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  29. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  30. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  33. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  34. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  35. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +2 -2
  37. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  38. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  39. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  40. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  41. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ultrawork/directive.md +18 -10
  44. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  45. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  46. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +18 -10
  47. package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +11 -0
  48. package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -1
  49. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +4 -0
  50. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +18 -10
  51. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -2
  52. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-steering.js +2 -1
  53. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -27
  54. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +6 -0
  55. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +55 -9
  56. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.d.ts +15 -0
  57. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.js +33 -0
  58. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-types.d.ts +10 -3
  59. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +15 -11
  60. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
  61. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  62. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  63. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +7 -7
  64. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +17 -2
  65. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts +2 -1
  66. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +59 -11
  67. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-snapshot.ts +38 -0
  68. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-types.ts +11 -3
  69. package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +15 -7
  70. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +16 -0
  71. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +260 -2
  72. package/packages/omo-codex/plugin/components/ulw-loop/test/steering-snapshot.test.ts +124 -0
  73. package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +101 -2
  74. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  77. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  78. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  79. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  80. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  81. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  82. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  83. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  84. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  85. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  86. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  87. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  88. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  89. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  90. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  91. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  92. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  93. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  94. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  95. package/packages/omo-codex/plugin/package-lock.json +13 -13
  96. package/packages/omo-codex/plugin/package.json +1 -1
  97. package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +10 -6
  98. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +9 -9
  99. package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +7 -3
  100. package/packages/omo-codex/plugin/skills/frontend/references/design/_INDEX.md +1 -1
  101. package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +24 -2
  102. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/README.md +2 -2
  103. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  104. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -2
  105. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +18 -10
  106. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  107. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +7 -7
  108. package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +11 -0
  109. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +13 -17
  110. package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +4 -4
  111. package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +1 -1
  112. package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +22 -9
  113. package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
  114. package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +52 -0
  115. package/packages/omo-codex/scripts/install-dist/install-local.mjs +65 -39
  116. package/packages/omo-codex/scripts/install-lazycodex-version-stamp.test.mjs +2 -2
  117. package/packages/shared-skills/skills/frontend/SKILL.md +9 -9
  118. package/packages/shared-skills/skills/frontend/references/design/README.md +7 -3
  119. package/packages/shared-skills/skills/frontend/references/design/_INDEX.md +1 -1
  120. package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +24 -2
  121. package/packages/shared-skills/skills/frontend/references/designpowers/README.md +2 -2
  122. package/packages/shared-skills/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  123. package/packages/shared-skills/skills/start-work/SKILL.md +2 -2
  124. package/packages/shared-skills/skills/visual-qa/SKILL.md +13 -17
  125. package/script/qa/strip-ansi.mjs +10 -0
  126. package/script/qa/web-terminal-visual-qa.mjs +112 -195
  127. package/script/qa/xterm-live-terminal.mjs +180 -0
  128. package/script/qa/web-terminal-renderer.mjs +0 -218
@@ -1,84 +1,64 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
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 { pathToFileURL } from "node:url";
7
- import {
8
- DEFAULT_TERMINAL_BACKGROUND,
9
- DEFAULT_TERMINAL_FOREGROUND,
10
- ansiColorCss,
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-capturable web page.
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" --from-file pane.txt --evidence-dir .omo/evidence/run
27
- node script/qa/web-terminal-visual-qa.mjs --title "OpenCode TUI" --command "opencode --help" --evidence-dir .omo/evidence/run
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
- --from-file <path> Replay an existing terminal/tmux transcript.
31
- --command <command> Run a command through a tmux-backed PTY connector, capture the pane, then clean up.
32
- --cwd <path> Working directory for --command. Defaults to current directory.
33
- --cols <n> Terminal columns for tmux connector. Default: 140.
34
- --rows <n> Terminal rows for tmux connector. Default: 40.
35
- --dwell-ms <n> Milliseconds to let --command render before capture. Default: 3000.
36
- --wrap Wrap long terminal lines in HTML/PNG evidence. Default.
37
- --no-wrap Preserve long lines with horizontal scrolling.
38
- --evidence-dir <path> Directory for terminal.txt, terminal-ansi.txt, terminal.html, terminal.png, metadata.json.
39
- --chrome-bin <path> Chrome/Chromium executable for PNG capture.
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 value to redact before writing evidence. Repeatable.
42
- --redact-regex <expr> JavaScript regex source to redact before writing evidence. Repeatable.
43
- --no-browser Skip PNG capture, but still write HTML/text/metadata.
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
- Terminal evidence is redacted before terminal.txt, terminal-ansi.txt, terminal.html, and terminal.png are written.
51
- Built-in rules cover common Authorization headers, token/password/key assignments, and GitHub/OpenAI-style tokens.
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 === "--no-browser") {
71
- args.browser = false;
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
- function writeHtml({ title, ansi, outPath, cols, wrap }) {
132
- const whiteSpace = wrap ? "pre-wrap" : "pre";
133
- const overflowWrap = wrap ? "anywhere" : "normal";
134
- const terminalWidth = terminalWidthCh(cols);
135
- const html = `<!doctype html>
136
- <html lang="en">
137
- <head>
138
- <meta charset="utf-8">
139
- <meta name="viewport" content="width=device-width, initial-scale=1">
140
- <title>${escapeHtml(title)}</title>
141
- <style>
142
- :root { color-scheme: dark; }
143
- body { margin: 0; background: #101217; color: ${DEFAULT_TERMINAL_FOREGROUND}; font: 13px/1.35 "SFMono-Regular", "Cascadia Mono", "JetBrains Mono", "Menlo", "Consolas", "Liberation Mono", ui-monospace, monospace; }
144
- main { min-height: 100vh; box-sizing: border-box; padding: 20px; }
145
- .terminal { width: min(100%, ${terminalWidth}ch); max-width: calc(100vw - 40px); border: 1px solid #3b4452; background: ${DEFAULT_TERMINAL_BACKGROUND}; box-shadow: 0 20px 80px rgb(0 0 0 / 40%); }
146
- .bar { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #303846; color: #aab7c4; background: #171b22; font-size: 12px; }
147
- .dot { width: 10px; height: 10px; border-radius: 999px; background: #6b7280; }
148
- pre { margin: 0; padding: 14px 16px; white-space: ${whiteSpace}; overflow-wrap: ${overflowWrap}; tab-size: 8; overflow: auto; }
149
- .ansi-bold { font-weight: 700; }
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 shellQuote(value) {
169
- return `'${value.replaceAll("'", "'\\''")}'`;
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 runTmuxCommand(args) {
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 htmlPath = join(evidenceDir, "terminal.html");
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
- const capture = args.fromFile ? captureFromFile(args.fromFile) : runTmuxCommand(args);
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
- writeHtml({ title: args.title, ansi: safeAnsi, outPath: htmlPath, cols: args.cols, wrap: args.wrap });
237
- const size = screenshotSize({ cols: args.cols, rows: args.rows });
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: capture.connector,
242
- browserCapture: browser.status,
145
+ connector: cap.connector,
146
+ colorPath: "xterm.js (true color; not tmux)",
147
+ browserCapture: cap.pngBuffer ? "captured" : "skipped",
243
148
  source: sourceMetadata(args),
244
- redaction: {
245
- builtInRules: BUILT_IN_REDACTION_RULE_COUNT,
246
- literalRules: args.redactions.length,
247
- regexRules: args.redactRegexes.length,
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)}\n${capture.cleanup}\n`);
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
- try {
259
- main();
260
- } catch (error) {
261
- const message = error instanceof Error ? error.message : String(error);
262
- process.stderr.write(`${message}\n`);
263
- process.exit(1);
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("")) 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 };