oh-my-codex 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +1 -0
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2940 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +4 -0
  11. package/crates/omx-sparkshell/src/main.rs +738 -29
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +479 -238
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/doctor-warning-copy.test.js +51 -0
  24. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  25. package/dist/cli/__tests__/explore.test.js +23 -0
  26. package/dist/cli/__tests__/explore.test.js.map +1 -1
  27. package/dist/cli/__tests__/index.test.js +123 -5
  28. package/dist/cli/__tests__/index.test.js.map +1 -1
  29. package/dist/cli/__tests__/launch-fallback.test.js +76 -0
  30. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  31. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  32. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  33. package/dist/cli/__tests__/question.test.js +45 -22
  34. package/dist/cli/__tests__/question.test.js.map +1 -1
  35. package/dist/cli/__tests__/setup-agents-overwrite.test.js +2 -0
  36. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  37. package/dist/cli/__tests__/setup-install-mode.test.js +138 -0
  38. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  39. package/dist/cli/__tests__/setup-scope.test.js +8 -2
  40. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  41. package/dist/cli/__tests__/sparkshell-cli.test.js +5 -0
  42. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  43. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  44. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  45. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  46. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  47. package/dist/cli/api.d.ts +26 -0
  48. package/dist/cli/api.d.ts.map +1 -0
  49. package/dist/cli/api.js +153 -0
  50. package/dist/cli/api.js.map +1 -0
  51. package/dist/cli/doctor.d.ts.map +1 -1
  52. package/dist/cli/doctor.js +39 -4
  53. package/dist/cli/doctor.js.map +1 -1
  54. package/dist/cli/explore.d.ts +2 -0
  55. package/dist/cli/explore.d.ts.map +1 -1
  56. package/dist/cli/explore.js +43 -1
  57. package/dist/cli/explore.js.map +1 -1
  58. package/dist/cli/index.d.ts +10 -4
  59. package/dist/cli/index.d.ts.map +1 -1
  60. package/dist/cli/index.js +128 -10
  61. package/dist/cli/index.js.map +1 -1
  62. package/dist/cli/native-assets.d.ts +2 -1
  63. package/dist/cli/native-assets.d.ts.map +1 -1
  64. package/dist/cli/native-assets.js +1 -0
  65. package/dist/cli/native-assets.js.map +1 -1
  66. package/dist/cli/setup.d.ts.map +1 -1
  67. package/dist/cli/setup.js +6 -1
  68. package/dist/cli/setup.js.map +1 -1
  69. package/dist/cli/sparkshell.d.ts.map +1 -1
  70. package/dist/cli/sparkshell.js +20 -3
  71. package/dist/cli/sparkshell.js.map +1 -1
  72. package/dist/config/generator.d.ts.map +1 -1
  73. package/dist/config/generator.js +90 -0
  74. package/dist/config/generator.js.map +1 -1
  75. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  76. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  77. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  78. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  79. package/dist/hooks/__tests__/keyword-detector.test.js +11 -0
  80. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  81. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  82. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  83. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  84. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  85. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  86. package/dist/hooks/keyword-registry.js +1 -0
  87. package/dist/hooks/keyword-registry.js.map +1 -1
  88. package/dist/hud/__tests__/reconcile.test.js +2 -2
  89. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  90. package/dist/hud/__tests__/tmux.test.js +23 -18
  91. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  92. package/dist/hud/tmux.d.ts.map +1 -1
  93. package/dist/hud/tmux.js +7 -6
  94. package/dist/hud/tmux.js.map +1 -1
  95. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  96. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  97. package/dist/mcp/bootstrap.d.ts +3 -1
  98. package/dist/mcp/bootstrap.d.ts.map +1 -1
  99. package/dist/mcp/bootstrap.js +71 -2
  100. package/dist/mcp/bootstrap.js.map +1 -1
  101. package/dist/scripts/__tests__/codex-native-hook.test.js +737 -26
  102. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  103. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  104. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  105. package/dist/scripts/__tests__/smoke-packed-install.test.js +4 -1
  106. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  107. package/dist/scripts/build-api.d.ts +2 -0
  108. package/dist/scripts/build-api.d.ts.map +1 -0
  109. package/dist/scripts/build-api.js +44 -0
  110. package/dist/scripts/build-api.js.map +1 -0
  111. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  112. package/dist/scripts/codex-native-hook.js +208 -8
  113. package/dist/scripts/codex-native-hook.js.map +1 -1
  114. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  115. package/dist/scripts/codex-native-pre-post.js +89 -24
  116. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  117. package/dist/scripts/notify-dispatcher.js +88 -0
  118. package/dist/scripts/notify-dispatcher.js.map +1 -1
  119. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  120. package/dist/scripts/notify-hook/team-dispatch.js +27 -9
  121. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  122. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  123. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  124. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  125. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +1 -0
  126. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  127. package/dist/scripts/notify-hook/team-tmux-guard.js +38 -0
  128. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  129. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  130. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  131. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  132. package/dist/scripts/run-provider-advisor.js +9 -3
  133. package/dist/scripts/run-provider-advisor.js.map +1 -1
  134. package/dist/scripts/smoke-packed-install.d.ts +1 -1
  135. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  136. package/dist/scripts/smoke-packed-install.js +2 -0
  137. package/dist/scripts/smoke-packed-install.js.map +1 -1
  138. package/dist/team/__tests__/runtime.test.js +2 -2
  139. package/dist/team/__tests__/runtime.test.js.map +1 -1
  140. package/dist/team/__tests__/tmux-session.test.js +153 -25
  141. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  142. package/dist/team/tmux-session.d.ts +1 -0
  143. package/dist/team/tmux-session.d.ts.map +1 -1
  144. package/dist/team/tmux-session.js +55 -10
  145. package/dist/team/tmux-session.js.map +1 -1
  146. package/dist/utils/__tests__/agents-md.test.js +45 -1
  147. package/dist/utils/__tests__/agents-md.test.js.map +1 -1
  148. package/dist/utils/agents-md.d.ts +2 -0
  149. package/dist/utils/agents-md.d.ts.map +1 -1
  150. package/dist/utils/agents-md.js +19 -0
  151. package/dist/utils/agents-md.js.map +1 -1
  152. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  153. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  154. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  155. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  156. package/package.json +4 -3
  157. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  158. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  159. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +1 -0
  160. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +1 -1
  161. package/prompts/researcher.md +15 -10
  162. package/skills/best-practice-research/SKILL.md +83 -0
  163. package/skills/deep-interview/SKILL.md +1 -0
  164. package/skills/ralplan/SKILL.md +1 -1
  165. package/src/scripts/__tests__/codex-native-hook.test.ts +810 -4
  166. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  167. package/src/scripts/__tests__/smoke-packed-install.test.ts +8 -2
  168. package/src/scripts/build-api.ts +48 -0
  169. package/src/scripts/codex-native-hook.ts +262 -10
  170. package/src/scripts/codex-native-pre-post.ts +103 -24
  171. package/src/scripts/notify-dispatcher.ts +97 -0
  172. package/src/scripts/notify-hook/team-dispatch.ts +27 -8
  173. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  174. package/src/scripts/notify-hook/team-tmux-guard.ts +42 -0
  175. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  176. package/src/scripts/run-provider-advisor.ts +11 -3
  177. package/src/scripts/smoke-packed-install.ts +2 -0
  178. package/templates/catalog-manifest.json +7 -0
@@ -1,7 +1,11 @@
1
1
  use std::env;
2
2
  use std::fs;
3
+ use std::io::{Read, Write};
4
+ use std::net::{TcpListener, TcpStream};
3
5
  use std::path::{Path, PathBuf};
4
6
  use std::process::Command;
7
+ use std::sync::{Arc, Mutex};
8
+ use std::thread::{self, JoinHandle};
5
9
  use std::time::{SystemTime, UNIX_EPOCH};
6
10
 
7
11
  fn sparkshell_bin() -> &'static str {
@@ -32,6 +36,80 @@ fn write_executable(path: &Path, body: &str) {
32
36
  }
33
37
  }
34
38
 
39
+ fn start_api_server<F>(expected_requests: usize, mut handler: F) -> (String, JoinHandle<()>)
40
+ where
41
+ F: FnMut(String) -> (u16, String) + Send + 'static,
42
+ {
43
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind api server");
44
+ let address = listener.local_addr().expect("api address");
45
+ let handle = thread::spawn(move || {
46
+ for _ in 0..expected_requests {
47
+ let (stream, _) = listener.accept().expect("accept api request");
48
+ let request = read_http_request(stream.try_clone().expect("clone stream"));
49
+ let (status, body) = handler(request);
50
+ write_http_response(stream, status, &body);
51
+ }
52
+ });
53
+ (format!("http://{}", address), handle)
54
+ }
55
+
56
+ fn read_http_request(mut stream: TcpStream) -> String {
57
+ let mut buffer = Vec::new();
58
+ let mut scratch = [0; 1024];
59
+ loop {
60
+ let count = stream.read(&mut scratch).expect("read request");
61
+ assert!(count > 0, "connection closed before request headers");
62
+ buffer.extend_from_slice(&scratch[..count]);
63
+ if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
64
+ break;
65
+ }
66
+ }
67
+ let request = String::from_utf8_lossy(&buffer).into_owned();
68
+ let content_length = request
69
+ .lines()
70
+ .find_map(|line| {
71
+ line.strip_prefix("Content-Length:")
72
+ .or_else(|| line.strip_prefix("content-length:"))
73
+ .and_then(|value| value.trim().parse::<usize>().ok())
74
+ })
75
+ .unwrap_or(0);
76
+ let body_start = buffer
77
+ .windows(4)
78
+ .position(|window| window == b"\r\n\r\n")
79
+ .expect("header boundary")
80
+ + 4;
81
+ while buffer.len() - body_start < content_length {
82
+ let count = stream.read(&mut scratch).expect("read request body");
83
+ assert!(count > 0, "connection closed before request body");
84
+ buffer.extend_from_slice(&scratch[..count]);
85
+ }
86
+ String::from_utf8_lossy(&buffer).into_owned()
87
+ }
88
+
89
+ fn write_http_response(mut stream: TcpStream, status: u16, body: &str) {
90
+ let reason = if (200..300).contains(&status) {
91
+ "OK"
92
+ } else {
93
+ "ERROR"
94
+ };
95
+ let response = format!(
96
+ "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
97
+ body.len()
98
+ );
99
+ stream
100
+ .write_all(response.as_bytes())
101
+ .expect("write response");
102
+ }
103
+
104
+ fn response_json(text: &str) -> String {
105
+ format!(
106
+ "{{\"object\":\"response\",\"output_text\":\"{}\"}}",
107
+ text.replace('\\', "\\\\")
108
+ .replace('"', "\\\"")
109
+ .replace('\n', "\\n")
110
+ )
111
+ }
112
+
35
113
  #[test]
36
114
  fn raw_mode_preserves_stdout_and_stderr() {
37
115
  let output = Command::new(sparkshell_bin())
@@ -48,27 +126,19 @@ fn raw_mode_preserves_stdout_and_stderr() {
48
126
  }
49
127
 
50
128
  #[test]
51
- fn summary_mode_uses_codex_exec_and_model_override() {
52
- let temp = unique_temp_dir("codex-success");
53
- let codex = temp.join("codex");
54
- let args_log = temp.join("args.log");
55
- let prompt_log = temp.join("prompt.log");
56
- write_executable(
57
- &codex,
58
- &format!(
59
- "#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\ncat > '{}'\nprintf '%s\n' '- summary: command produced long output' '- warnings: stderr was empty'\n",
60
- args_log.display(),
61
- prompt_log.display()
62
- ),
63
- );
129
+ fn summary_mode_uses_local_api_and_model_override() {
130
+ let request_log = Arc::new(Mutex::new(String::new()));
131
+ let request_log_for_server = Arc::clone(&request_log);
132
+ let (base_url, server) = start_api_server(1, move |request| {
133
+ *request_log_for_server.lock().expect("request log") = request;
134
+ (
135
+ 200,
136
+ response_json("- summary: command produced long output\n- warnings: stderr was empty"),
137
+ )
138
+ });
64
139
 
65
- let path = format!(
66
- "{}:{}",
67
- temp.display(),
68
- env::var("PATH").unwrap_or_default()
69
- );
70
140
  let output = Command::new(sparkshell_bin())
71
- .env("PATH", path)
141
+ .env("OMX_API_BASE_URL", base_url)
72
142
  .env("OMX_SPARKSHELL_LINES", "1")
73
143
  .env("OMX_SPARKSHELL_MODEL", "spark-test-model")
74
144
  .arg("sh")
@@ -76,6 +146,7 @@ fn summary_mode_uses_codex_exec_and_model_override() {
76
146
  .arg("printf 'one\ntwo\n'")
77
147
  .output()
78
148
  .expect("run sparkshell");
149
+ server.join().expect("api server");
79
150
 
80
151
  assert!(output.status.success());
81
152
  let stdout = String::from_utf8_lossy(&output.stdout);
@@ -83,42 +154,65 @@ fn summary_mode_uses_codex_exec_and_model_override() {
83
154
  assert!(stdout.contains("- warnings: stderr was empty"));
84
155
  assert!(String::from_utf8_lossy(&output.stderr).is_empty());
85
156
 
86
- let args = fs::read_to_string(args_log).expect("args log");
87
- assert!(args.contains("exec"));
88
- assert!(args.contains("--model"));
89
- assert!(args.contains("spark-test-model"));
90
- assert!(args.contains("model_reasoning_effort=\"low\""));
157
+ let request = request_log.lock().expect("request log");
158
+ assert!(request.starts_with("POST /v1/responses HTTP/1.1"));
159
+ assert!(request.contains("\"model\":\"spark-test-model\""));
160
+ assert!(request.contains("\"reasoning\":{\"effort\":\"low\"}"));
161
+ assert!(request.contains("Command family: generic-shell"));
162
+ assert!(request.contains("<<<STDOUT"));
163
+ assert!(request.contains("one\\ntwo"));
164
+ }
91
165
 
92
- let prompt = fs::read_to_string(prompt_log).expect("prompt log");
93
- assert!(prompt.contains("Command family: generic-shell"));
94
- assert!(prompt.contains("<<<STDOUT"));
95
- assert!(prompt.contains("one\ntwo"));
166
+ #[test]
167
+ fn summary_mode_redacts_secret_like_output_before_prompt_request() {
168
+ let request_log = Arc::new(Mutex::new(String::new()));
169
+ let request_log_for_server = Arc::clone(&request_log);
170
+ let (base_url, server) = start_api_server(1, move |request| {
171
+ *request_log_for_server.lock().expect("request log") = request;
172
+ (200, response_json("- summary: redacted output summarized"))
173
+ });
96
174
 
97
- let _ = fs::remove_dir_all(temp);
175
+ let output = Command::new(sparkshell_bin())
176
+ .env("OMX_API_BASE_URL", base_url)
177
+ .env("OMX_SPARKSHELL_LINES", "1")
178
+ .env("CHILD_API_TOKEN", "super-secret-token")
179
+ .env("CHILD_BEARER", "bearer-secret-token")
180
+ .arg("sh")
181
+ .arg("-c")
182
+ .arg("printf 'API_TOKEN=%s\\nline-2\\n' \"$CHILD_API_TOKEN\"; printf 'Authorization: Bearer %s\\n' \"$CHILD_BEARER\" >&2")
183
+ .output()
184
+ .expect("run sparkshell");
185
+ server.join().expect("api server");
186
+
187
+ assert!(output.status.success());
188
+ assert!(String::from_utf8_lossy(&output.stdout).contains("redacted output summarized"));
189
+
190
+ let request = request_log.lock().expect("request log");
191
+ assert!(request.contains("API_TOKEN=[REDACTED]"));
192
+ assert!(request.contains("Authorization: Bearer [REDACTED]"));
193
+ assert!(request.contains("line-2"));
194
+ assert!(!request.contains("super-secret-token"));
195
+ assert!(!request.contains("bearer-secret-token"));
98
196
  }
99
197
 
100
198
  #[test]
101
199
  fn summary_mode_injects_model_instructions_file_override() {
102
- let temp = unique_temp_dir("codex-instructions-file");
103
- let codex = temp.join("codex");
104
- let args_log = temp.join("args.log");
200
+ let temp = unique_temp_dir("api-instructions-file");
105
201
  let instructions_file = temp.join("sparkshell-lightweight-AGENTS.md");
106
- write_executable(
107
- &codex,
108
- &format!(
109
- "#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\nprintf '%s\n' '- summary: command produced long output'\n",
110
- args_log.display()
111
- ),
112
- );
113
202
  fs::write(&instructions_file, "# sparkshell instructions\n").expect("write instructions file");
114
203
 
115
- let path = format!(
116
- "{}:{}",
117
- temp.display(),
118
- env::var("PATH").unwrap_or_default()
119
- );
204
+ let request_log = Arc::new(Mutex::new(String::new()));
205
+ let request_log_for_server = Arc::clone(&request_log);
206
+ let (base_url, server) = start_api_server(1, move |request| {
207
+ *request_log_for_server.lock().expect("request log") = request;
208
+ (
209
+ 200,
210
+ response_json("- summary: command produced long output"),
211
+ )
212
+ });
213
+
120
214
  let output = Command::new(sparkshell_bin())
121
- .env("PATH", path)
215
+ .env("OMX_API_BASE_URL", base_url)
122
216
  .env("OMX_SPARKSHELL_LINES", "1")
123
217
  .env(
124
218
  "OMX_SPARKSHELL_MODEL_INSTRUCTIONS_FILE",
@@ -129,87 +223,63 @@ fn summary_mode_injects_model_instructions_file_override() {
129
223
  .arg("printf 'one\ntwo\n'")
130
224
  .output()
131
225
  .expect("run sparkshell");
226
+ server.join().expect("api server");
132
227
 
133
228
  assert!(output.status.success());
134
- let args = fs::read_to_string(args_log).expect("args log");
135
- assert!(args.contains("model_reasoning_effort=\"low\""));
136
- assert!(args.contains(&format!(
137
- "model_instructions_file=\"{}\"",
138
- instructions_file
139
- .display()
140
- .to_string()
141
- .replace('\\', "\\\\")
142
- .replace('"', "\\\"")
143
- )));
229
+ let request = request_log.lock().expect("request log");
230
+ assert!(request.contains("\"reasoning\":{\"effort\":\"low\"}"));
231
+ assert!(request.contains("\"instructions\":\"# sparkshell instructions\\n\""));
144
232
 
145
233
  let _ = fs::remove_dir_all(temp);
146
234
  }
147
235
 
148
236
  #[test]
149
237
  fn summary_failure_falls_back_to_raw_output_with_notice() {
150
- let temp = unique_temp_dir("codex-fail");
151
- let codex = temp.join("codex");
152
- write_executable(
153
- &codex,
154
- "#!/bin/sh\nprintf '%s\n' 'bridge failed' >&2\nexit 9\n",
155
- );
238
+ let (base_url, server) = start_api_server(1, |_request| {
239
+ (503, "{\"error\":\"bridge failed\"}".to_string())
240
+ });
156
241
 
157
- let path = format!(
158
- "{}:{}",
159
- temp.display(),
160
- env::var("PATH").unwrap_or_default()
161
- );
162
242
  let output = Command::new(sparkshell_bin())
163
- .env("PATH", path)
243
+ .env("OMX_API_BASE_URL", base_url)
164
244
  .env("OMX_SPARKSHELL_LINES", "1")
165
245
  .arg("/bin/sh")
166
246
  .arg("-c")
167
247
  .arg("printf 'one\ntwo\n'; printf 'child-err\n' >&2")
168
248
  .output()
169
249
  .expect("run sparkshell");
250
+ server.join().expect("api server");
170
251
 
171
252
  assert!(output.status.success());
172
253
  assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
173
254
  let stderr = String::from_utf8_lossy(&output.stderr);
174
255
  assert!(stderr.contains("child-err"));
175
256
  assert!(stderr.contains("summary unavailable"));
176
-
177
- let _ = fs::remove_dir_all(temp);
178
257
  }
179
258
 
180
259
  #[test]
181
260
  fn summary_mode_retries_with_fallback_model_when_spark_is_unavailable() {
182
- let temp = unique_temp_dir("codex-fallback-model");
183
- let codex = temp.join("codex");
184
- let args_log = temp.join("args.log");
185
- write_executable(
186
- &codex,
187
- &format!(
188
- "#!/bin/sh
189
- printf '%s\n' \"$@\" >> '{}'
190
- model=''
191
- prev=''
192
- for arg in \"$@\"; do
193
- if [ \"$prev\" = '--model' ]; then model=\"$arg\"; fi
194
- prev=\"$arg\"
195
- done
196
- if [ \"$model\" = 'spark-test-model' ]; then
197
- printf '%s\n' 'rate limit exceeded for spark model' >&2
198
- exit 17
199
- fi
200
- printf '%s\n' '- summary: fallback model recovered summary'
201
- ",
202
- args_log.display()
203
- ),
204
- );
261
+ let request_log = Arc::new(Mutex::new(Vec::new()));
262
+ let request_log_for_server = Arc::clone(&request_log);
263
+ let (base_url, server) = start_api_server(2, move |request| {
264
+ request_log_for_server
265
+ .lock()
266
+ .expect("request log")
267
+ .push(request.clone());
268
+ if request.contains("\"model\":\"spark-test-model\"") {
269
+ (
270
+ 429,
271
+ "{\"error\":\"rate limit exceeded for spark model\"}".to_string(),
272
+ )
273
+ } else {
274
+ (
275
+ 200,
276
+ response_json("- summary: fallback model recovered summary"),
277
+ )
278
+ }
279
+ });
205
280
 
206
- let path = format!(
207
- "{}:{}",
208
- temp.display(),
209
- env::var("PATH").unwrap_or_default()
210
- );
211
281
  let output = Command::new(sparkshell_bin())
212
- .env("PATH", path)
282
+ .env("OMX_API_BASE_URL", base_url)
213
283
  .env("OMX_SPARKSHELL_LINES", "1")
214
284
  .env("OMX_SPARKSHELL_MODEL", "spark-test-model")
215
285
  .env("OMX_SPARKSHELL_FALLBACK_MODEL", "frontier-test-model")
@@ -218,47 +288,36 @@ printf '%s\n' '- summary: fallback model recovered summary'
218
288
  .arg("printf 'one\ntwo\n'")
219
289
  .output()
220
290
  .expect("run sparkshell");
291
+ server.join().expect("api server");
221
292
 
222
293
  assert!(output.status.success());
223
294
  assert!(String::from_utf8_lossy(&output.stdout).contains("fallback model recovered summary"));
224
295
  assert!(String::from_utf8_lossy(&output.stderr).is_empty());
225
296
 
226
- let args = fs::read_to_string(args_log).expect("args log");
227
- assert!(args.contains("spark-test-model"));
228
- assert!(args.contains("frontier-test-model"));
229
-
230
- let _ = fs::remove_dir_all(temp);
297
+ let requests = request_log.lock().expect("request log");
298
+ assert_eq!(requests.len(), 2);
299
+ assert!(requests[0].contains("\"model\":\"spark-test-model\""));
300
+ assert!(requests[1].contains("\"model\":\"frontier-test-model\""));
231
301
  }
232
302
 
233
303
  #[test]
234
304
  fn summary_mode_reports_both_models_when_fallback_also_fails() {
235
- let temp = unique_temp_dir("codex-fallback-model-fail");
236
- let codex = temp.join("codex");
237
- write_executable(
238
- &codex,
239
- "#!/bin/sh
240
- model=''
241
- prev=''
242
- for arg in \"$@\"; do
243
- if [ \"$prev\" = '--model' ]; then model=\"$arg\"; fi
244
- prev=\"$arg\"
245
- done
246
- if [ \"$model\" = 'spark-test-model' ]; then
247
- printf '%s\n' 'quota exhausted for spark model' >&2
248
- exit 17
249
- fi
250
- printf '%s\n' 'fallback backend unavailable' >&2
251
- exit 29
252
- ",
253
- );
305
+ let (base_url, server) = start_api_server(2, |request| {
306
+ if request.contains("\"model\":\"spark-test-model\"") {
307
+ (
308
+ 429,
309
+ "{\"error\":\"quota exhausted for spark model\"}".to_string(),
310
+ )
311
+ } else {
312
+ (
313
+ 503,
314
+ "{\"error\":\"fallback backend unavailable\"}".to_string(),
315
+ )
316
+ }
317
+ });
254
318
 
255
- let path = format!(
256
- "{}:{}",
257
- temp.display(),
258
- env::var("PATH").unwrap_or_default()
259
- );
260
319
  let output = Command::new(sparkshell_bin())
261
- .env("PATH", path)
320
+ .env("OMX_API_BASE_URL", base_url)
262
321
  .env("OMX_SPARKSHELL_LINES", "1")
263
322
  .env("OMX_SPARKSHELL_MODEL", "spark-test-model")
264
323
  .env("OMX_SPARKSHELL_FALLBACK_MODEL", "frontier-test-model")
@@ -267,6 +326,7 @@ exit 29
267
326
  .arg("printf 'one\ntwo\n'; printf 'child-err\n' >&2")
268
327
  .output()
269
328
  .expect("run sparkshell");
329
+ server.join().expect("api server");
270
330
 
271
331
  assert!(output.status.success());
272
332
  assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
@@ -274,48 +334,34 @@ exit 29
274
334
  assert!(stderr.contains("child-err"));
275
335
  assert!(stderr.contains("primary model `spark-test-model`"));
276
336
  assert!(stderr.contains("fallback model `frontier-test-model`"));
277
-
278
- let _ = fs::remove_dir_all(temp);
279
337
  }
280
338
 
281
339
  #[test]
282
340
  fn summary_mode_preserves_child_exit_code() {
283
- let temp = unique_temp_dir("codex-exit");
284
- let codex = temp.join("codex");
285
- write_executable(
286
- &codex,
287
- "#!/bin/sh\nprintf '%s\n' '- failures: command exited non-zero'\n",
288
- );
341
+ let (base_url, server) = start_api_server(1, |_request| {
342
+ (200, response_json("- failures: command exited non-zero"))
343
+ });
289
344
 
290
- let path = format!(
291
- "{}:{}",
292
- temp.display(),
293
- env::var("PATH").unwrap_or_default()
294
- );
295
345
  let output = Command::new(sparkshell_bin())
296
- .env("PATH", path)
346
+ .env("OMX_API_BASE_URL", base_url)
297
347
  .env("OMX_SPARKSHELL_LINES", "1")
298
348
  .arg("sh")
299
349
  .arg("-c")
300
350
  .arg("printf 'one\ntwo\n'; exit 7")
301
351
  .output()
302
352
  .expect("run sparkshell");
353
+ server.join().expect("api server");
303
354
 
304
355
  assert_eq!(output.status.code(), Some(7));
305
356
  assert!(String::from_utf8_lossy(&output.stdout).contains("- failures: command exited non-zero"));
306
357
  assert!(String::from_utf8_lossy(&output.stderr).is_empty());
307
-
308
- let _ = fs::remove_dir_all(temp);
309
358
  }
310
359
 
311
360
  #[test]
312
361
  fn tmux_pane_mode_captures_large_tail_and_summarizes() {
313
362
  let temp = unique_temp_dir("tmux-pane-summary");
314
363
  let tmux = temp.join("tmux");
315
- let codex = temp.join("codex");
316
364
  let args_log = temp.join("tmux-args.log");
317
- let prompt_log = temp.join("pane-prompt.log");
318
-
319
365
  write_executable(
320
366
  &tmux,
321
367
  &format!(
@@ -323,13 +369,16 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
323
369
  args_log.display()
324
370
  ),
325
371
  );
326
- write_executable(
327
- &codex,
328
- &format!(
329
- "#!/bin/sh\ncat > '{}'\nprintf '%s\n' '- summary: tmux pane summarized' '- warnings: tail captured'\n",
330
- prompt_log.display()
331
- ),
332
- );
372
+
373
+ let request_log = Arc::new(Mutex::new(String::new()));
374
+ let request_log_for_server = Arc::clone(&request_log);
375
+ let (base_url, server) = start_api_server(1, move |request| {
376
+ *request_log_for_server.lock().expect("request log") = request;
377
+ (
378
+ 200,
379
+ response_json("- summary: tmux pane summarized\n- warnings: tail captured"),
380
+ )
381
+ });
333
382
 
334
383
  let path = format!(
335
384
  "{}:{}",
@@ -338,6 +387,7 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
338
387
  );
339
388
  let output = Command::new(sparkshell_bin())
340
389
  .env("PATH", path)
390
+ .env("OMX_API_BASE_URL", base_url)
341
391
  .env("OMX_SPARKSHELL_LINES", "1")
342
392
  .arg("--tmux-pane")
343
393
  .arg("%17")
@@ -345,6 +395,7 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
345
395
  .arg("400")
346
396
  .output()
347
397
  .expect("run sparkshell");
398
+ server.join().expect("api server");
348
399
 
349
400
  assert!(output.status.success());
350
401
  let stdout = String::from_utf8_lossy(&output.stdout);
@@ -356,36 +407,16 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
356
407
  assert!(tmux_args.contains("%17"));
357
408
  assert!(tmux_args.contains("-400"));
358
409
 
359
- let prompt = fs::read_to_string(prompt_log).expect("prompt log");
360
- assert!(prompt.contains("Command: tmux capture-pane"));
361
- assert!(prompt.contains("line-1"));
410
+ let request = request_log.lock().expect("request log");
411
+ assert!(request.contains("Command: tmux capture-pane"));
412
+ assert!(request.contains("line-1"));
362
413
 
363
414
  let _ = fs::remove_dir_all(temp);
364
415
  }
365
416
 
366
417
  #[test]
367
418
  fn raw_mode_keeps_boundary_output_without_summary() {
368
- let temp = unique_temp_dir("boundary-raw");
369
- let codex = temp.join("codex");
370
- let codex_log = temp.join("codex.log");
371
- write_executable(
372
- &codex,
373
- &format!(
374
- "#!/bin/sh
375
- printf '%s\n' invoked > '{}'
376
- exit 0
377
- ",
378
- codex_log.display()
379
- ),
380
- );
381
-
382
- let path = format!(
383
- "{}:{}",
384
- temp.display(),
385
- env::var("PATH").unwrap_or_default()
386
- );
387
419
  let output = Command::new(sparkshell_bin())
388
- .env("PATH", path)
389
420
  .env("OMX_SPARKSHELL_LINES", "2")
390
421
  .arg("sh")
391
422
  .arg("-c")
@@ -394,67 +425,47 @@ exit 0
394
425
  .expect("run sparkshell");
395
426
 
396
427
  assert!(output.status.success());
397
- assert_eq!(
398
- String::from_utf8_lossy(&output.stdout),
399
- "one
400
- two
401
- "
402
- );
403
- assert!(
404
- !codex_log.exists(),
405
- "codex should not run at the raw/summary boundary"
406
- );
407
-
408
- let _ = fs::remove_dir_all(temp);
428
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
409
429
  }
410
430
 
411
431
  #[test]
412
432
  fn summary_mode_uses_combined_stdout_and_stderr_threshold() {
413
- let temp = unique_temp_dir("combined-threshold");
414
- let codex = temp.join("codex");
415
- let prompt_log = temp.join("prompt.log");
416
- write_executable(
417
- &codex,
418
- &format!(
419
- "#!/bin/sh
420
- cat > '{}'
421
- printf '%s\n' '- summary: combined output exceeded threshold'
422
- ",
423
- prompt_log.display()
424
- ),
425
- );
433
+ let request_log = Arc::new(Mutex::new(String::new()));
434
+ let request_log_for_server = Arc::clone(&request_log);
435
+ let (base_url, server) = start_api_server(1, move |request| {
436
+ *request_log_for_server.lock().expect("request log") = request;
437
+ (
438
+ 200,
439
+ response_json("- summary: combined output exceeded threshold"),
440
+ )
441
+ });
426
442
 
427
- let path = format!(
428
- "{}:{}",
429
- temp.display(),
430
- env::var("PATH").unwrap_or_default()
431
- );
432
443
  let output = Command::new(sparkshell_bin())
433
- .env("PATH", path)
444
+ .env("OMX_API_BASE_URL", base_url)
434
445
  .env("OMX_SPARKSHELL_LINES", "2")
435
446
  .arg("sh")
436
447
  .arg("-c")
437
448
  .arg("printf 'one\n' && printf 'warn\nextra\n' >&2")
438
449
  .output()
439
450
  .expect("run sparkshell");
451
+ server.join().expect("api server");
440
452
 
441
453
  assert!(output.status.success());
442
454
  assert!(String::from_utf8_lossy(&output.stdout).contains("combined output exceeded threshold"));
443
- let prompt = fs::read_to_string(prompt_log).expect("prompt log");
444
- assert!(prompt.contains("<<<STDERR"));
445
- assert!(prompt.contains(
446
- "warn
447
- extra"
448
- ));
449
-
450
- let _ = fs::remove_dir_all(temp);
455
+ let request = request_log.lock().expect("request log");
456
+ assert!(request.contains("<<<STDERR"));
457
+ assert!(request.contains("warn\\nextra"));
451
458
  }
452
459
 
453
460
  #[test]
454
- fn summary_failure_when_codex_is_missing_falls_back_to_raw_output() {
455
- let empty_path = unique_temp_dir("missing-codex");
461
+ fn summary_failure_when_api_is_missing_falls_back_to_raw_output() {
462
+ let listener = TcpListener::bind("127.0.0.1:0").expect("reserve port");
463
+ let base_url = format!("http://{}", listener.local_addr().expect("address"));
464
+ drop(listener);
465
+
456
466
  let output = Command::new(sparkshell_bin())
457
- .env("PATH", empty_path.display().to_string())
467
+ .env("OMX_API_BASE_URL", base_url)
468
+ .env("OMX_SPARKSHELL_SUMMARY_TIMEOUT_MS", "500")
458
469
  .env("OMX_SPARKSHELL_LINES", "1")
459
470
  .arg("/bin/sh")
460
471
  .arg("-c")
@@ -463,41 +474,27 @@ fn summary_failure_when_codex_is_missing_falls_back_to_raw_output() {
463
474
  .expect("run sparkshell");
464
475
 
465
476
  assert!(output.status.success());
466
- assert_eq!(
467
- String::from_utf8_lossy(&output.stdout),
468
- "one
469
- two
470
- "
471
- );
477
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
472
478
  let stderr = String::from_utf8_lossy(&output.stderr);
473
479
  assert!(stderr.contains("child-err"));
474
480
  assert!(stderr.contains("summary unavailable"));
475
-
476
- let _ = fs::remove_dir_all(empty_path);
477
481
  }
478
482
 
479
483
  #[test]
480
484
  fn tmux_pane_mode_uses_default_tail_lines_when_not_overridden() {
481
485
  let temp = unique_temp_dir("tmux-default-tail");
482
486
  let tmux = temp.join("tmux");
483
- let codex = temp.join("codex");
484
487
  let args_log = temp.join("tmux-args.log");
485
488
  write_executable(
486
489
  &tmux,
487
490
  &format!(
488
- "#!/bin/sh
489
- printf '%s\n' \"$@\" > '{}'
490
- printf 'line-1\nline-2\nline-3\n'
491
- ",
491
+ "#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\nprintf 'line-1\nline-2\nline-3\n'\n",
492
492
  args_log.display()
493
493
  ),
494
494
  );
495
- write_executable(
496
- &codex,
497
- "#!/bin/sh
498
- printf '%s\n' '- summary: used default tmux tail'
499
- ",
500
- );
495
+ let (base_url, server) = start_api_server(1, |_request| {
496
+ (200, response_json("- summary: used default tmux tail"))
497
+ });
501
498
 
502
499
  let path = format!(
503
500
  "{}:{}",
@@ -506,11 +503,13 @@ printf '%s\n' '- summary: used default tmux tail'
506
503
  );
507
504
  let output = Command::new(sparkshell_bin())
508
505
  .env("PATH", path)
506
+ .env("OMX_API_BASE_URL", base_url)
509
507
  .env("OMX_SPARKSHELL_LINES", "1")
510
508
  .arg("--tmux-pane")
511
509
  .arg("%21")
512
510
  .output()
513
511
  .expect("run sparkshell");
512
+ server.join().expect("api server");
514
513
 
515
514
  assert!(output.status.success());
516
515
  let tmux_args = fs::read_to_string(args_log).expect("tmux args");
@@ -519,3 +518,245 @@ printf '%s\n' '- summary: used default tmux tail'
519
518
 
520
519
  let _ = fs::remove_dir_all(temp);
521
520
  }
521
+
522
+ #[test]
523
+ fn summary_module_does_not_shell_out_to_codex() {
524
+ let source =
525
+ fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/codex_bridge.rs"))
526
+ .expect("source");
527
+ assert!(!source.contains("Command::new(\"codex\")"));
528
+ assert!(!source.contains(".arg(\"exec\")"));
529
+ assert!(!source.contains("codex exec"));
530
+ }
531
+
532
+ #[test]
533
+ fn json_mode_emits_machine_readable_contract() {
534
+ let output = Command::new(sparkshell_bin())
535
+ .arg("--json")
536
+ .arg("sh")
537
+ .arg("-c")
538
+ .arg("printf 'ok\n'")
539
+ .output()
540
+ .expect("run sparkshell");
541
+
542
+ assert!(output.status.success());
543
+ let stdout = String::from_utf8_lossy(&output.stdout);
544
+ assert!(stdout.contains("\"ok\": true"));
545
+ assert!(stdout.contains("\"mode\": \"command\""));
546
+ assert!(stdout.contains("\"status\": \"ok\""));
547
+ assert!(stdout.contains("\"summary\":"));
548
+ assert!(stdout.contains("\"evidence\":"));
549
+ assert!(stdout.contains("\"raw_hash\":"));
550
+ }
551
+
552
+ #[test]
553
+ fn json_mode_reports_failed_command_details() {
554
+ let output = Command::new(sparkshell_bin())
555
+ .arg("--json")
556
+ .arg("sh")
557
+ .arg("-c")
558
+ .arg("printf 'bad\n' >&2; exit 9")
559
+ .output()
560
+ .expect("run sparkshell");
561
+
562
+ assert_eq!(output.status.code(), Some(9));
563
+ let stdout = String::from_utf8_lossy(&output.stdout);
564
+ assert!(stdout.contains("\"ok\": false"));
565
+ assert!(stdout.contains("\"status\": \"failed\""));
566
+ assert!(stdout.contains("\"exit_code\": 9"));
567
+ assert!(stdout.contains("bad"));
568
+ }
569
+
570
+ #[test]
571
+ fn json_mode_classifies_auth_errors() {
572
+ let output = Command::new(sparkshell_bin())
573
+ .arg("--json")
574
+ .arg("sh")
575
+ .arg("-c")
576
+ .arg("printf 'Authorization failed\n' >&2; exit 1")
577
+ .output()
578
+ .expect("run sparkshell");
579
+
580
+ assert_eq!(output.status.code(), Some(1));
581
+ let stdout = String::from_utf8_lossy(&output.stdout);
582
+ assert!(stdout.contains("\"classification\": \"auth_error\""));
583
+ assert!(stdout.contains("authentication-like error"));
584
+ }
585
+
586
+ #[test]
587
+ fn direct_command_preserves_child_json_flag() {
588
+ let temp = unique_temp_dir("child-json-flag");
589
+ let script = temp.join("echo-argv");
590
+ write_executable(
591
+ &script,
592
+ r#"#!/usr/bin/env bash
593
+ printf '%s\n' "$@"
594
+ "#,
595
+ );
596
+
597
+ let output = Command::new(sparkshell_bin())
598
+ .arg(script)
599
+ .arg("--json")
600
+ .arg("value")
601
+ .output()
602
+ .expect("run sparkshell");
603
+
604
+ assert!(output.status.success());
605
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "--json\nvalue\n");
606
+ let _ = fs::remove_dir_all(temp);
607
+ }
608
+
609
+ #[test]
610
+ fn team_diagnostics_reads_last_turn_at_heartbeat() {
611
+ let temp = unique_temp_dir("last-turn-heartbeat");
612
+ let worker_dir = temp.join("team/demo/workers/worker-1");
613
+ fs::create_dir_all(&worker_dir).expect("worker dir");
614
+ fs::write(
615
+ worker_dir.join("heartbeat.json"),
616
+ r#"{"last_turn_at":"1970-01-01T00:00:00.000Z"}"#,
617
+ )
618
+ .expect("heartbeat");
619
+
620
+ let output = Command::new(sparkshell_bin())
621
+ .env("OMX_TEAM_STATE_ROOT", temp.display().to_string())
622
+ .arg("--json")
623
+ .arg("--team")
624
+ .arg("demo")
625
+ .arg("--worker")
626
+ .arg("worker-1")
627
+ .arg("printf")
628
+ .arg("ok\n")
629
+ .output()
630
+ .expect("run sparkshell");
631
+
632
+ assert!(output.status.success());
633
+ assert!(
634
+ String::from_utf8_lossy(&output.stdout).contains("\"classification\": \"stale_heartbeat\"")
635
+ );
636
+ let _ = fs::remove_dir_all(temp);
637
+ }
638
+
639
+ #[test]
640
+ fn json_mode_reads_team_state_from_env_root() {
641
+ let temp = unique_temp_dir("team-state");
642
+ let worker_dir = temp.join("team/demo/workers/worker-1");
643
+ fs::create_dir_all(&worker_dir).expect("worker dir");
644
+ fs::write(
645
+ worker_dir.join("status.json"),
646
+ r#"{"state":"busy","task":"in_progress"}"#,
647
+ )
648
+ .expect("status");
649
+
650
+ let output = Command::new(sparkshell_bin())
651
+ .env("OMX_TEAM_STATE_ROOT", temp.display().to_string())
652
+ .arg("--json")
653
+ .arg("--team")
654
+ .arg("demo")
655
+ .arg("--worker")
656
+ .arg("worker-1")
657
+ .arg("sh")
658
+ .arg("-c")
659
+ .arg("printf 'quiet\n'")
660
+ .output()
661
+ .expect("run sparkshell");
662
+
663
+ assert!(output.status.success());
664
+ let stdout = String::from_utf8_lossy(&output.stdout);
665
+ assert!(stdout.contains("\"classification\": \"busy_processing\""));
666
+ assert!(stdout.contains("do not shutdown yet"));
667
+
668
+ let _ = fs::remove_dir_all(temp);
669
+ }
670
+
671
+ #[test]
672
+ fn pane_json_cache_reports_hits_and_since_last_changes() {
673
+ let temp = unique_temp_dir("pane-cache");
674
+ let tmux = temp.join("tmux");
675
+ let cache = temp.join("cache");
676
+ let pane = temp.join("pane.txt");
677
+ fs::write(&pane, "line-1\nline-2\n").expect("pane");
678
+ write_executable(&tmux, &format!("#!/bin/sh\ncat {}\n", pane.display()));
679
+ let path = format!(
680
+ "{}:{}",
681
+ temp.display(),
682
+ env::var("PATH").unwrap_or_default()
683
+ );
684
+
685
+ let first = Command::new(sparkshell_bin())
686
+ .env("PATH", &path)
687
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
688
+ .arg("--json")
689
+ .arg("--tmux-pane")
690
+ .arg("%31")
691
+ .output()
692
+ .expect("first");
693
+ assert!(first.status.success());
694
+ assert!(String::from_utf8_lossy(&first.stdout).contains("\"cache_hit\":false"));
695
+
696
+ let second = Command::new(sparkshell_bin())
697
+ .env("PATH", &path)
698
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
699
+ .arg("--json")
700
+ .arg("--tmux-pane")
701
+ .arg("%31")
702
+ .output()
703
+ .expect("second");
704
+ assert!(second.status.success());
705
+ assert!(String::from_utf8_lossy(&second.stdout).contains("\"cache_hit\":true"));
706
+
707
+ fs::write(&pane, "line-1\nline-2\nline-3\n").expect("pane update");
708
+ let third = Command::new(sparkshell_bin())
709
+ .env("PATH", &path)
710
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
711
+ .arg("--json")
712
+ .arg("--since-last")
713
+ .arg("--tmux-pane")
714
+ .arg("%31")
715
+ .output()
716
+ .expect("third");
717
+ assert!(third.status.success());
718
+ let stdout = String::from_utf8_lossy(&third.stdout);
719
+ assert!(stdout.contains("\"changed_line_ranges\":[\"3-3\"]"));
720
+ assert!(stdout.contains("new findings since last observation"));
721
+ assert!(stdout.contains("line-3"));
722
+
723
+ let _ = fs::remove_dir_all(temp);
724
+ }
725
+
726
+ #[test]
727
+ fn raw_mode_preserves_non_utf8_bytes() {
728
+ let temp = unique_temp_dir("raw-non-utf8");
729
+ let script = temp.join("raw-bytes");
730
+ write_executable(
731
+ &script,
732
+ r#"#!/usr/bin/env bash
733
+ printf '\xff\xfe\n'
734
+ "#,
735
+ );
736
+
737
+ let output = Command::new(sparkshell_bin())
738
+ .arg(script)
739
+ .output()
740
+ .expect("run sparkshell");
741
+
742
+ assert!(output.status.success());
743
+ assert_eq!(output.stdout, vec![0xff, 0xfe, b'\n']);
744
+ let _ = fs::remove_dir_all(temp);
745
+ }
746
+
747
+ #[test]
748
+ fn shell_mode_executes_explicit_shell_and_redacts_json_output() {
749
+ let output = Command::new(sparkshell_bin())
750
+ .arg("--json")
751
+ .arg("--shell")
752
+ .arg("printf 'left && right\n'; printf 'Authorization: Bearer secret-token\n' >&2")
753
+ .output()
754
+ .expect("run sparkshell");
755
+
756
+ assert!(output.status.success());
757
+ let stdout = String::from_utf8_lossy(&output.stdout);
758
+ assert!(stdout.contains("\"mode\": \"shell\""));
759
+ assert!(stdout.contains("left && right"));
760
+ assert!(stdout.contains("Authorization: Bearer [REDACTED]"));
761
+ assert!(stdout.contains("\"redactions\": {\"count\": 1}"));
762
+ }