pi-cursor-sdk 0.1.15 → 0.1.17

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 (46) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +20 -8
  3. package/docs/cursor-live-smoke-checklist.md +267 -0
  4. package/docs/cursor-model-ux-spec.md +15 -5
  5. package/docs/cursor-native-tool-replay.md +16 -5
  6. package/package.json +12 -5
  7. package/scripts/steering-rpc-smoke.mjs +238 -0
  8. package/scripts/tmux-live-smoke.sh +418 -0
  9. package/scripts/validate-smoke-jsonl.mjs +152 -0
  10. package/src/context.ts +180 -5
  11. package/src/cursor-bridge-contract.ts +27 -0
  12. package/src/cursor-edit-diff.ts +11 -0
  13. package/src/cursor-env-boolean.ts +22 -0
  14. package/src/cursor-live-run-accounting.ts +65 -0
  15. package/src/cursor-live-run-coordinator.ts +483 -0
  16. package/src/cursor-native-tool-display-registration.ts +93 -0
  17. package/src/cursor-native-tool-display-replay.ts +465 -0
  18. package/src/cursor-native-tool-display-state.ts +78 -0
  19. package/src/cursor-native-tool-display-tools.ts +102 -0
  20. package/src/cursor-native-tool-display.ts +10 -639
  21. package/src/cursor-partial-content-emitter.ts +121 -0
  22. package/src/cursor-pi-tool-bridge-abort.ts +133 -0
  23. package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
  24. package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
  25. package/src/cursor-pi-tool-bridge-run.ts +384 -0
  26. package/src/cursor-pi-tool-bridge-server.ts +182 -0
  27. package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
  28. package/src/cursor-pi-tool-bridge-types.ts +80 -0
  29. package/src/cursor-pi-tool-bridge.ts +77 -602
  30. package/src/cursor-provider-live-run-drain.ts +379 -0
  31. package/src/cursor-provider-turn-coordinator.ts +456 -0
  32. package/src/cursor-provider.ts +133 -1092
  33. package/src/cursor-question-tool.ts +7 -2
  34. package/src/cursor-record-utils.ts +26 -0
  35. package/src/cursor-sdk-output-filter.ts +100 -0
  36. package/src/cursor-sensitive-text.ts +37 -0
  37. package/src/cursor-session-agent.ts +372 -0
  38. package/src/cursor-session-cwd.ts +14 -19
  39. package/src/cursor-session-scope.ts +65 -0
  40. package/src/cursor-state.ts +38 -10
  41. package/src/cursor-tool-transcript.ts +28 -1229
  42. package/src/cursor-transcript-tool-formatters.ts +641 -0
  43. package/src/cursor-transcript-tool-specs.ts +441 -0
  44. package/src/cursor-transcript-utils.ts +276 -0
  45. package/src/cursor-usage-accounting.ts +71 -0
  46. package/src/index.ts +20 -3
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RPC steering smoke: queue steer after a native-replay tool-use turn completes execution.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = fileURLToPath(new URL("..", import.meta.url));
11
+ const CHILD_SHUTDOWN_GRACE_MS = 2_000;
12
+
13
+ function printHelp() {
14
+ console.log(`RPC steering smoke for pi-cursor-sdk live runs.
15
+
16
+ Usage:
17
+ node scripts/steering-rpc-smoke.mjs
18
+
19
+ Environment:
20
+ SMOKE_SESSION_DIR Session directory for the RPC pi run. Defaults to /tmp/pi-cursor-steer-smoke-<timestamp>.
21
+ CURSOR_API_KEY Required Cursor API key for live pi runs.
22
+
23
+ Options:
24
+ -h, --help Show this help.
25
+
26
+ Exit codes:
27
+ 0 steering scenario completed without AgentBusyError; STEER_OK and STEER_CHAIN present
28
+ 1 validation failure, timeout, AgentBusyError, or non-zero pi exit
29
+
30
+ Notes:
31
+ - Runs pi in RPC mode with native tool replay enabled and the pi bridge disabled.
32
+ - Sends steer after the replayed bash tool finishes execution (post toolResult boundary).
33
+ - Prints a single JSON result line on success; errors go to stderr.`);
34
+ }
35
+
36
+ function fail(message) {
37
+ throw new Error(message);
38
+ }
39
+
40
+ function parseEvents(stdout) {
41
+ const events = [];
42
+ for (const line of stdout.split("\n")) {
43
+ if (!line.trim()) continue;
44
+ try {
45
+ events.push(JSON.parse(line));
46
+ } catch {
47
+ // ignore partial lines
48
+ }
49
+ }
50
+ return events;
51
+ }
52
+
53
+ function assistantText(events) {
54
+ return events
55
+ .filter((event) => event.type === "message_end" && event.message?.role === "assistant")
56
+ .map((event) =>
57
+ (event.message.content ?? [])
58
+ .filter((block) => block.type === "text")
59
+ .map((block) => block.text)
60
+ .join("\n"),
61
+ )
62
+ .join("\n");
63
+ }
64
+
65
+ function hasToolUseTurn(events) {
66
+ return events.some(
67
+ (event) =>
68
+ event.type === "message_end" &&
69
+ event.message?.role === "assistant" &&
70
+ event.message?.stopReason === "toolUse",
71
+ );
72
+ }
73
+
74
+ function hasToolExecutionEnd(events) {
75
+ return events.some((event) => event.type === "tool_execution_end");
76
+ }
77
+
78
+ function waitFor(getStdout, predicate, timeoutMs = 300_000) {
79
+ const start = Date.now();
80
+ return new Promise((resolve, reject) => {
81
+ const tick = () => {
82
+ const events = parseEvents(getStdout());
83
+ if (predicate(events)) {
84
+ resolve(events);
85
+ return;
86
+ }
87
+ if (Date.now() - start > timeoutMs) {
88
+ reject(
89
+ new Error(
90
+ `timeout after ${timeoutMs}ms\nassistantText=${assistantText(events)}\nstdoutTail=${getStdout().slice(-4000)}`,
91
+ ),
92
+ );
93
+ return;
94
+ }
95
+ setTimeout(tick, 500);
96
+ };
97
+ tick();
98
+ });
99
+ }
100
+
101
+ function waitForChildClose(child) {
102
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(child.exitCode ?? 1);
103
+ return new Promise((resolve) => {
104
+ child.once("close", (code) => resolve(code ?? 1));
105
+ });
106
+ }
107
+
108
+ function signalChild(child, signal) {
109
+ if (!child.pid) return;
110
+ try {
111
+ if (process.platform === "win32") {
112
+ child.kill(signal);
113
+ } else {
114
+ process.kill(-child.pid, signal);
115
+ }
116
+ } catch {
117
+ try {
118
+ child.kill(signal);
119
+ } catch {
120
+ // child already exited
121
+ }
122
+ }
123
+ }
124
+
125
+ async function terminateChild(child) {
126
+ child.stdin.destroy();
127
+ if (child.exitCode !== null || child.signalCode !== null) return;
128
+ signalChild(child, "SIGTERM");
129
+ const killTimer = setTimeout(() => signalChild(child, "SIGKILL"), CHILD_SHUTDOWN_GRACE_MS);
130
+ try {
131
+ await waitForChildClose(child);
132
+ } finally {
133
+ clearTimeout(killTimer);
134
+ }
135
+ }
136
+
137
+ async function runPiRpcSmoke(sessionDir) {
138
+ const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2.5", "--mode", "rpc", "--session-dir", sessionDir];
139
+ const env = {
140
+ ...process.env,
141
+ PI_CURSOR_SETTING_SOURCES: "none",
142
+ PI_CURSOR_NATIVE_TOOL_DISPLAY: "1",
143
+ PI_CURSOR_PI_TOOL_BRIDGE: "0",
144
+ };
145
+
146
+ const child = spawn("pi", args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
147
+ let closed = false;
148
+ let stdout = "";
149
+ let stderr = "";
150
+ child.stdout.on("data", (chunk) => {
151
+ stdout += chunk.toString();
152
+ });
153
+ child.stderr.on("data", (chunk) => {
154
+ stderr += chunk.toString();
155
+ });
156
+
157
+ const send = (obj) => {
158
+ if (!child.stdin.writable) fail("pi stdin closed before smoke command could be sent");
159
+ child.stdin.write(`${JSON.stringify(obj)}\n`);
160
+ };
161
+
162
+ try {
163
+ send({
164
+ type: "prompt",
165
+ message:
166
+ "Steering smoke. Use bash once to run: git status --short. Do not answer until after the tool completes. Final answer must include STEER_OK=yes.",
167
+ });
168
+
169
+ await waitFor(
170
+ () => stdout,
171
+ (events) => hasToolUseTurn(events),
172
+ );
173
+
174
+ await waitFor(
175
+ () => stdout,
176
+ (events) => hasToolExecutionEnd(events),
177
+ );
178
+
179
+ send({ type: "steer", message: "and also include STEER_CHAIN=ok in the final answer" });
180
+
181
+ await waitFor(
182
+ () => stdout,
183
+ (events) => {
184
+ const text = assistantText(events);
185
+ return text.includes("STEER_OK=yes") && text.includes("STEER_CHAIN=ok") && events.some((event) => event.type === "agent_end");
186
+ },
187
+ );
188
+
189
+ const combined = stdout + stderr;
190
+ if (/already has active run|AgentBusyError/i.test(combined)) {
191
+ fail("AgentBusyError detected in smoke output");
192
+ }
193
+
194
+ const text = assistantText(parseEvents(stdout));
195
+ if (!text.includes("STEER_OK=yes")) {
196
+ fail(`missing STEER_OK=yes in assistant output: ${text.slice(0, 500)}`);
197
+ }
198
+ if (!text.includes("STEER_CHAIN=ok")) {
199
+ fail(`missing STEER_CHAIN=ok in assistant output: ${text.slice(0, 500)}`);
200
+ }
201
+
202
+ child.stdin.end();
203
+ const exitCode = await waitForChildClose(child);
204
+ closed = true;
205
+ if (exitCode !== 0) {
206
+ fail(`pi exited ${exitCode}\nstderr=${stderr.slice(-2000)}`);
207
+ }
208
+
209
+ return {
210
+ ok: true,
211
+ sessionDir,
212
+ steerOk: true,
213
+ steerChain: true,
214
+ };
215
+ } finally {
216
+ if (!closed) await terminateChild(child);
217
+ }
218
+ }
219
+
220
+ async function main() {
221
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
222
+ printHelp();
223
+ return;
224
+ }
225
+
226
+ if (!process.env.CURSOR_API_KEY) {
227
+ fail("steering-rpc-smoke: CURSOR_API_KEY is required");
228
+ }
229
+
230
+ const sessionDir = process.env.SMOKE_SESSION_DIR ?? join("/tmp", `pi-cursor-steer-smoke-${Date.now()}`);
231
+ mkdirSync(sessionDir, { recursive: true });
232
+ console.log(JSON.stringify(await runPiRpcSmoke(sessionDir)));
233
+ }
234
+
235
+ main().catch((error) => {
236
+ console.error(error instanceof Error ? error.message : String(error));
237
+ process.exitCode = 1;
238
+ });
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ SMOKE_DIR="${SMOKE_DIR:-/tmp/pi-cursor-sdk-live-smoke-$(date +%Y%m%dT%H%M%S)}"
6
+ SHELL_BIN="${SHELL:-/bin/bash}"
7
+
8
+ PI_BASE=(
9
+ pi -e "$ROOT"
10
+ --cursor-no-fast
11
+ --model cursor/composer-2.5
12
+ )
13
+
14
+ TMUX_SESSIONS=()
15
+
16
+ cleanup() {
17
+ local session
18
+ for session in "${TMUX_SESSIONS[@]:-}"; do
19
+ tmux kill-session -t "$session" 2>/dev/null || true
20
+ done
21
+ }
22
+ trap cleanup EXIT
23
+
24
+ print_help() {
25
+ cat <<EOF
26
+ Partial live smoke runner for pi-cursor-sdk (subset of docs/cursor-live-smoke-checklist.md).
27
+
28
+ Usage:
29
+ ./scripts/tmux-live-smoke.sh
30
+ SMOKE_DIR=/tmp/pi-cursor-smoke ./scripts/tmux-live-smoke.sh
31
+
32
+ Environment:
33
+ SMOKE_DIR Artifact directory. Defaults to /tmp/pi-cursor-sdk-live-smoke-<timestamp>.
34
+ CURSOR_API_KEY Required for live Cursor runs.
35
+
36
+ Prerequisites:
37
+ pi, node, rg, tmux on PATH
38
+ timeout or gtimeout optional; bash process-group kill fallback is used when absent
39
+
40
+ Coverage:
41
+ - prereq model listing
42
+ - basic non-interactive prompt (retry-empty-output; strict output assertion)
43
+ - default ambient settings prompt (strict; no retry)
44
+ - simple non-interactive math prompt (strict; no retry)
45
+ - interactive TUI math/footer polling with cleanup
46
+ - RPC steering after native replay tool execution (tmux-isolated)
47
+ - diagnostics safety scan
48
+ - JSONL assistant usage validation
49
+
50
+ Not covered here:
51
+ bridge MCP, standalone native replay, abort/cancel, packaging, full checklist sections 4-9
52
+
53
+ Options:
54
+ -h, --help Show this help.
55
+
56
+ Exit codes:
57
+ 0 all partial checks passed
58
+ 1 prerequisite, smoke, safety, or JSONL validation failure
59
+ EOF
60
+ }
61
+
62
+ log() {
63
+ printf '[smoke] %s\n' "$*"
64
+ }
65
+
66
+ fail() {
67
+ printf '[smoke] FAIL: %s\n' "$*" >&2
68
+ exit 1
69
+ }
70
+
71
+ require_cmd() {
72
+ command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
73
+ }
74
+
75
+ run_with_timeout() {
76
+ local timeout_secs="$1"
77
+ shift
78
+ if command -v timeout >/dev/null 2>&1; then
79
+ timeout "$timeout_secs" "$@"
80
+ return $?
81
+ fi
82
+ if command -v gtimeout >/dev/null 2>&1; then
83
+ gtimeout "$timeout_secs" "$@"
84
+ return $?
85
+ fi
86
+
87
+ local restore_monitor=0
88
+ case $- in
89
+ *m*) ;;
90
+ *)
91
+ restore_monitor=1
92
+ set -m
93
+ ;;
94
+ esac
95
+
96
+ "$@" &
97
+ local pid=$!
98
+ (
99
+ sleep "$timeout_secs"
100
+ kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
101
+ sleep 2
102
+ kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
103
+ ) &
104
+ local watcher=$!
105
+ local code=0
106
+ if wait "$pid"; then
107
+ code=0
108
+ else
109
+ code=$?
110
+ fi
111
+ kill "$watcher" 2>/dev/null || true
112
+ wait "$watcher" 2>/dev/null || true
113
+ if (( restore_monitor )); then
114
+ set +m
115
+ fi
116
+ return "$code"
117
+ }
118
+
119
+ tail_file() {
120
+ local file="$1"
121
+ local lines="${2:-80}"
122
+ if [[ -s "$file" ]]; then
123
+ tail -n "$lines" "$file" || true
124
+ else
125
+ printf '<empty: %s>\n' "$file"
126
+ fi
127
+ }
128
+
129
+ assert_file_contains() {
130
+ local name="$1"
131
+ local file="$2"
132
+ local pattern="$3"
133
+ local label="$4"
134
+ if ! rg -q "$pattern" "$file"; then
135
+ printf '[smoke] %s missing %s in %s\n' "$name" "$label" "$file" >&2
136
+ printf '[smoke] %s transcript tail:\n' "$name" >&2
137
+ tail_file "$file" 120 >&2
138
+ fail "$name missing ${label}"
139
+ fi
140
+ }
141
+
142
+ is_empty_retryable_exit() {
143
+ local code="$1"
144
+ local stdout="$2"
145
+ [[ ! -s "$stdout" && ( "$code" == "0" || "$code" == "124" || "$code" == "137" || "$code" == "143" ) ]]
146
+ }
147
+
148
+ run_direct_attempt() {
149
+ local name="$1"
150
+ local timeout_secs="$2"
151
+ local stdout="$3"
152
+ local stderr="$4"
153
+ shift 4
154
+ rm -f "$stdout" "$stderr"
155
+
156
+ if run_with_timeout "$timeout_secs" "$@" >"$stdout" 2>"$stderr"; then
157
+ return 0
158
+ fi
159
+ return $?
160
+ }
161
+
162
+ run_direct_fail() {
163
+ local name="$1"
164
+ local code="$2"
165
+ local stdout="$3"
166
+ local stderr="$4"
167
+ local label="$5"
168
+ if [[ "$code" != "0" ]]; then
169
+ cat "$stderr" >&2 || true
170
+ fail "$name exited $code"
171
+ fi
172
+ printf '[smoke] %s missing %s in %s\n' "$name" "$label" "$stdout" >&2
173
+ printf '[smoke] %s stdout tail:\n' "$name" >&2
174
+ tail_file "$stdout" 120 >&2
175
+ printf '[smoke] %s stderr tail:\n' "$name" >&2
176
+ tail_file "$stderr" 80 >&2
177
+ fail "$name missing ${label}"
178
+ }
179
+
180
+ run_direct() {
181
+ local name="$1"
182
+ local timeout_secs="$2"
183
+ local policy="$3"
184
+ local expected_pattern="$4"
185
+ local expected_label="$5"
186
+ shift 5
187
+ local stdout="$SMOKE_DIR/${name}.stdout.txt"
188
+ local stderr="$SMOKE_DIR/${name}.stderr.txt"
189
+ local code=0
190
+
191
+ if run_direct_attempt "$name" "$timeout_secs" "$stdout" "$stderr" "$@"; then
192
+ code=0
193
+ else
194
+ code=$?
195
+ fi
196
+ if [[ "$code" == "0" ]] && rg -q "$expected_pattern" "$stdout"; then
197
+ log "$name PASS"
198
+ return 0
199
+ fi
200
+
201
+ case "$policy" in
202
+ strict)
203
+ run_direct_fail "$name" "$code" "$stdout" "$stderr" "$expected_label"
204
+ ;;
205
+ retry-empty-output)
206
+ local first_stdout="$SMOKE_DIR/${name}.attempt1.stdout.txt"
207
+ local first_stderr="$SMOKE_DIR/${name}.attempt1.stderr.txt"
208
+ if ! is_empty_retryable_exit "$code" "$stdout"; then
209
+ run_direct_fail "$name" "$code" "$stdout" "$stderr" "$expected_label"
210
+ fi
211
+ mv "$stdout" "$first_stdout" 2>/dev/null || true
212
+ mv "$stderr" "$first_stderr" 2>/dev/null || true
213
+ log "$name retrying once after empty output with exit $code"
214
+ if run_direct_attempt "$name" "$timeout_secs" "$stdout" "$stderr" "$@"; then
215
+ local retry_code=0
216
+ if rg -q "$expected_pattern" "$stdout"; then
217
+ log "$name PASS after retry (first exit $code; first stderr: $first_stderr)"
218
+ return 0
219
+ fi
220
+ printf '[smoke] %s retry exited %s but still missed %s\n' "$name" "$retry_code" "$expected_label" >&2
221
+ else
222
+ local retry_code=$?
223
+ printf '[smoke] %s retry exited %s after first empty output exit %s\n' "$name" "$retry_code" "$code" >&2
224
+ fi
225
+ printf '[smoke] %s first stdout tail:\n' "$name" >&2
226
+ tail_file "$first_stdout" 80 >&2
227
+ printf '[smoke] %s first stderr tail:\n' "$name" >&2
228
+ tail_file "$first_stderr" 80 >&2
229
+ printf '[smoke] %s retry stdout tail:\n' "$name" >&2
230
+ tail_file "$stdout" 120 >&2
231
+ printf '[smoke] %s retry stderr tail:\n' "$name" >&2
232
+ tail_file "$stderr" 80 >&2
233
+ fail "$name retry failed after empty output"
234
+ ;;
235
+ *)
236
+ fail "$name unknown run_direct policy: $policy (expected strict or retry-empty-output)"
237
+ ;;
238
+ esac
239
+ }
240
+
241
+ quote_command() {
242
+ local quoted=()
243
+ local arg
244
+ for arg in "$@"; do
245
+ printf -v arg '%q' "$arg"
246
+ quoted+=("$arg")
247
+ done
248
+ printf '%s ' "${quoted[@]}"
249
+ }
250
+
251
+ run_tui_math_footer_poll() {
252
+ local name="$1"
253
+ local timeout_secs="$2"
254
+ shift 2
255
+ local session="pi-cursor-smoke-${name}-$$"
256
+ local capture="$SMOKE_DIR/${name}.capture.txt"
257
+ local script
258
+ local command
259
+ command="$(quote_command "$@")"
260
+ rm -f "$capture"
261
+
262
+ printf -v script 'cd %q || exit 97
263
+ exec %s
264
+ ' "$ROOT" "$command"
265
+ tmux new-session -d -s "$session" -x 120 -y 40 -- "$SHELL_BIN" -lc "$script"
266
+ TMUX_SESSIONS+=("$session")
267
+
268
+ local elapsed=0
269
+ local missing=""
270
+ while true; do
271
+ tmux capture-pane -pt "$session" >"$capture" 2>/dev/null || true
272
+ missing=""
273
+ rg -q "SUM=42" "$capture" || missing="${missing} SUM=42"
274
+ rg -q "\\(cursor\\) composer-2\\.5" "$capture" || missing="${missing} footer (cursor) composer-2.5"
275
+ if [[ -z "$missing" ]]; then
276
+ tmux kill-session -t "$session" 2>/dev/null || true
277
+ log "$name PASS"
278
+ return 0
279
+ fi
280
+
281
+ sleep 2
282
+ elapsed=$((elapsed + 2))
283
+ if (( elapsed >= timeout_secs )); then
284
+ tmux kill-session -t "$session" 2>/dev/null || true
285
+ printf '[smoke] %s timed out after %ss; missing:%s\n' "$name" "$timeout_secs" "$missing" >&2
286
+ printf '[smoke] %s capture tail:\n' "$name" >&2
287
+ tail_file "$capture" 120 >&2
288
+ fail "$name timed out waiting for TUI evidence"
289
+ fi
290
+ done
291
+ }
292
+
293
+ run_tmux() {
294
+ local name="$1"
295
+ local timeout_secs="$2"
296
+ local dump_stderr_on_fail="$3"
297
+ shift 3
298
+ local session="pi-cursor-smoke-${name}-$$"
299
+ local marker="$SMOKE_DIR/${name}.done"
300
+ local stdout="$SMOKE_DIR/${name}.stdout.txt"
301
+ local stderr="$SMOKE_DIR/${name}.stderr.txt"
302
+ local command
303
+ local script
304
+ command="$(quote_command "$@")"
305
+ rm -f "$marker" "$stdout" "$stderr"
306
+
307
+ printf -v script 'cd %q || exit 97
308
+ %s> %q 2> %q
309
+ code=$?
310
+ printf '\''%%s\n'\'' "$code" > %q
311
+ ' "$ROOT" "$command" "$stdout" "$stderr" "$marker"
312
+ tmux new-session -d -s "$session" -- "$SHELL_BIN" -lc "$script"
313
+ TMUX_SESSIONS+=("$session")
314
+
315
+ local elapsed=0
316
+ while [[ ! -f "$marker" ]]; do
317
+ sleep 2
318
+ elapsed=$((elapsed + 2))
319
+ if (( elapsed >= timeout_secs )); then
320
+ tmux capture-pane -pt "$session" >"$SMOKE_DIR/${name}.capture.txt" || true
321
+ tmux kill-session -t "$session" 2>/dev/null || true
322
+ fail "$name timed out after ${timeout_secs}s (see ${name}.capture.txt)"
323
+ fi
324
+ done
325
+
326
+ local code
327
+ code="$(cat "$marker")"
328
+ tmux kill-session -t "$session" 2>/dev/null || true
329
+ if [[ "$code" != "0" ]]; then
330
+ if [[ "$dump_stderr_on_fail" == "1" ]]; then
331
+ cat "$stderr" >&2 || true
332
+ fi
333
+ fail "$name exited $code"
334
+ fi
335
+ log "$name PASS"
336
+ }
337
+
338
+ model_listed() {
339
+ local file="$1"
340
+ rg -q "composer-2\\.5" "$file"
341
+ }
342
+
343
+ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
344
+ print_help
345
+ exit 0
346
+ fi
347
+
348
+ require_cmd pi
349
+ require_cmd node
350
+ require_cmd rg
351
+ require_cmd tmux
352
+
353
+ if [[ -z "${CURSOR_API_KEY:-}" ]]; then
354
+ fail "CURSOR_API_KEY is required"
355
+ fi
356
+
357
+ mkdir -p "$SMOKE_DIR"
358
+ printf '%s\n' "$SMOKE_DIR" >"$SMOKE_DIR/smoke-dir.txt"
359
+
360
+ log "SMOKE_DIR=$SMOKE_DIR"
361
+ log "partial live smoke: prereq, basic, default-settings, noninteractive-math, tui, steering, diagnostics, jsonl"
362
+
363
+ if ! PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" --list-models cursor 2>"$SMOKE_DIR/prereq.stderr.txt" | tee "$SMOKE_DIR/prereq.models.txt" | rg -q "composer-2\\.5"; then
364
+ if ! model_listed "$SMOKE_DIR/prereq.stderr.txt"; then
365
+ fail "cursor/composer-2.5 not listed"
366
+ fi
367
+ fi
368
+ log "prereq PASS"
369
+
370
+ run_direct basic 600 retry-empty-output "PI_CURSOR_SMOKE_OK" "PI_CURSOR_SMOKE_OK" \
371
+ env PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" \
372
+ --session-dir "$SMOKE_DIR/basic" \
373
+ --no-tools \
374
+ -p 'Live smoke. Reply exactly: PI_CURSOR_SMOKE_OK'
375
+
376
+ run_direct default-settings 300 strict "PRODUCT=42" "PRODUCT=42" \
377
+ "${PI_BASE[@]}" \
378
+ --session-dir "$SMOKE_DIR/default-settings" \
379
+ --no-tools \
380
+ -p 'Default settings smoke. Include PRODUCT=42 in the final answer.'
381
+
382
+ run_direct noninteractive-math 300 strict "SUM=42" "SUM=42" \
383
+ env PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" \
384
+ --session-dir "$SMOKE_DIR/noninteractive-math" \
385
+ --no-tools \
386
+ -p 'Noninteractive math smoke. Compute 19 + 23. Reply only with SUM=42.'
387
+
388
+ run_tui_math_footer_poll tui 420 \
389
+ env PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" \
390
+ --session-dir "$SMOKE_DIR/tui" \
391
+ --no-tools \
392
+ 'TUI smoke. Compute 19 + 23. Reply only with SUM=<number>.'
393
+
394
+ run_tmux steering 420 1 \
395
+ env "SMOKE_SESSION_DIR=$SMOKE_DIR/steering" node "$ROOT/scripts/steering-rpc-smoke.mjs"
396
+ rg -q '"steerOk":true' "$SMOKE_DIR/steering.stdout.txt" || fail "steering missing steerOk"
397
+ rg -q '"steerChain":true' "$SMOKE_DIR/steering.stdout.txt" || fail "steering missing steerChain"
398
+ rg -q "already has active run|AgentBusyError" "$SMOKE_DIR/steering.stdout.txt" "$SMOKE_DIR/steering.stderr.txt" && fail "steering hit AgentBusyError" || true
399
+
400
+ forbidden_files="$(find "$SMOKE_DIR" -type f \( -name '*stderr.txt' -o -name '*capture*.txt' \) -print0 |
401
+ xargs -0 grep -IlE 'CURSOR_API_KEY|Bearer [A-Za-z0-9._-]+|/cursor-pi-tool-bridge/[^ ]+/mcp|127\.0\.0\.1:[0-9]+/cursor-pi-tool-bridge|apiKey|cookie|session-cookie|secret-token' || true)"
402
+ if [[ -n "$forbidden_files" ]]; then
403
+ printf '[smoke] diagnostics safety scan found forbidden material in:\n' >&2
404
+ while IFS= read -r file; do
405
+ [[ -z "$file" ]] && continue
406
+ if [[ "$file" == "$SMOKE_DIR/"* ]]; then
407
+ printf '[smoke] %s\n' "${file#"$SMOKE_DIR/"}" >&2
408
+ else
409
+ printf '[smoke] %s\n' "$file" >&2
410
+ fi
411
+ done <<<"$forbidden_files"
412
+ fail "diagnostics safety scan found forbidden material"
413
+ fi
414
+ log "diagnostics safety PASS"
415
+
416
+ node "$ROOT/scripts/validate-smoke-jsonl.mjs" "$SMOKE_DIR"
417
+ log "jsonl structural scan PASS"
418
+ log "partial live smoke checks passed (see --help for uncovered checklist sections)"