@pageai/ralph-loop 1.18.0 → 1.21.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.
package/.agent/PROMPT.md CHANGED
@@ -1,3 +1,5 @@
1
+ > ⛔ **ONE TASK PER INVOCATION** — Complete one task from @.agent/tasks.json, commit, output `<promise>TASK-{ID}:DONE</promise>`, and STOP. Do NOT start the next task. Do NOT use parallel agents for multiple tasks.
2
+
1
3
  ## Overview
2
4
 
3
5
  You are implementing the project described in @.agent/prd/SUMMARY.md
@@ -28,7 +30,7 @@ Tasks are listed in @.agent/tasks.json
28
30
  7. Run `tsc` and unit tests project-wide
29
31
  8. All tests must pass. Broke unrelated test? Fix it before proceeding.
30
32
  9. When tests pass, set `passes: true` in `tasks.json` for the task you completed.
31
- 10. Log entry → `.agent/logs/LOG.md` (date, brief summary, screenshot path)
33
+ 10. Log entry → `.agent/logs/LOG.md` (date, brief summary, screenshot path, newest at the top)
32
34
  11. Update `.agent/STRUCTURE.md` if dirs changed. Exclude dotfiles, tests and config.
33
35
  12. Commit changes, using the Conventional Commit format.
34
36
 
@@ -51,6 +53,15 @@ When stuck after all possible solutions exhausted, output one of the following t
51
53
  <promise>BLOCKED:brief description</promise>
52
54
  ```
53
55
 
56
+ **Exit immediately (no workarounds) for environment constraints you cannot fix from inside the sandbox:**
57
+
58
+ - `Blocked by network policy` → firewall, only user can change from host
59
+ - Missing/invalid credentials or API keys
60
+ - Required system service unavailable
61
+ - Hardware/arch incompatibility with no known fix
62
+
63
+ These are not bugs. No amount of retries, alternative downloads, or package managers will help. Output BLOCKED on first failure.
64
+
54
65
  2. **DECIDE** — need human input: lib choices, architecture, unclear requirements, breaking changes. Output:
55
66
 
56
67
  ```
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse hook that enforces the one-task-per-invocation rule.
5
+ *
6
+ * Reads .agent/tasks.json, counts tasks with passes: true,
7
+ * and warns if more than one task was completed since the session started.
8
+ * The baseline is stored in .agent/.task-count (ephemeral per session).
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // Find project root by looking for .agent directory
15
+ function findProjectRoot() {
16
+ let dir = __dirname;
17
+ // Walk up from .claude/hooks
18
+ while (dir !== path.dirname(dir)) {
19
+ if (fs.existsSync(path.join(dir, '.agent', 'tasks.json'))) {
20
+ return dir;
21
+ }
22
+ dir = path.dirname(dir);
23
+ }
24
+ return null;
25
+ }
26
+
27
+ // Read tool input from stdin to check if this was a git commit
28
+ function readStdin() {
29
+ try {
30
+ return fs.readFileSync('/dev/stdin', 'utf8');
31
+ } catch {
32
+ return '';
33
+ }
34
+ }
35
+
36
+ try {
37
+ const input = readStdin();
38
+
39
+ // Only check after git commit commands
40
+ let parsed = {};
41
+ try {
42
+ parsed = JSON.parse(input);
43
+ } catch {
44
+ // Not JSON input, skip
45
+ process.exit(0);
46
+ }
47
+
48
+ const toolInput = parsed.tool_input || {};
49
+ const command = toolInput.command || '';
50
+
51
+ // Only trigger on git commit commands
52
+ if (!command.includes('git commit')) {
53
+ process.exit(0);
54
+ }
55
+
56
+ const root = findProjectRoot();
57
+ if (!root) {
58
+ process.exit(0);
59
+ }
60
+
61
+ const agentDir = path.join(root, '.agent');
62
+ const tasksFile = path.join(agentDir, 'tasks.json');
63
+ const countFile = path.join(agentDir, '.task-count');
64
+
65
+ if (!fs.existsSync(tasksFile)) {
66
+ process.exit(0);
67
+ }
68
+
69
+ const tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8'));
70
+ const passCount = tasks.filter((t) => t.passes === true).length;
71
+
72
+ if (!fs.existsSync(countFile)) {
73
+ // First commit this session — store baseline
74
+ fs.writeFileSync(countFile, String(passCount));
75
+ } else {
76
+ const baseline = parseInt(fs.readFileSync(countFile, 'utf8'), 10);
77
+ const completed = passCount - baseline;
78
+
79
+ if (completed > 1) {
80
+ console.log(
81
+ `⚠️ VIOLATION: ${completed} tasks completed in this invocation. ` +
82
+ 'PROMPT.md requires ONE task per invocation. ' +
83
+ 'Stop now and output <promise>TASK-{ID}:DONE</promise>.'
84
+ );
85
+ }
86
+ }
87
+ } catch (error) {
88
+ // Fail silently — don't block work if the guard has a bug
89
+ if (process.env.DEBUG) {
90
+ console.error(`one-task-guard error: ${error.message}`);
91
+ }
92
+ }
@@ -26,7 +26,7 @@ function findClaudeDir() {
26
26
 
27
27
  try {
28
28
  const claudeDir = findClaudeDir();
29
- const awFilePath = path.join(claudeDir, 'commands', 'aw.md');
29
+ const awFilePath = path.join(claudeDir, 'hooks', 'aw.md');
30
30
 
31
31
  if (fs.existsSync(awFilePath)) {
32
32
  const content = fs.readFileSync(awFilePath, 'utf8');
@@ -7,23 +7,22 @@
7
7
  "enableAllProjectMcpServers": true,
8
8
  "enabledMcpjsonServers": ["playwright", "context7", "sequential-thinking"],
9
9
  "hooks": {
10
- "UserPromptSubmit": [
10
+ "PostToolUse": [
11
11
  {
12
+ "matcher": "Edit|Write|Bash",
12
13
  "hooks": [
13
14
  {
14
15
  "type": "command",
15
- "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.js"
16
+ "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.js"
16
17
  }
17
18
  ]
18
- }
19
- ],
20
- "PreToolUse": [
19
+ },
21
20
  {
22
- "matcher": "Edit|Write|Bash",
21
+ "matcher": "Bash",
23
22
  "hooks": [
24
23
  {
25
24
  "type": "command",
26
- "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.js"
25
+ "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/one-task-guard.js"
27
26
  }
28
27
  ]
29
28
  }
package/AGENTS.md CHANGED
@@ -25,6 +25,10 @@ Signs a file needs splitting:
25
25
  - "Utils" file becoming a junk drawer
26
26
  - Component doing data fetching + transformation + rendering
27
27
 
28
+ ## Task Execution
29
+
30
+ - **One task per invocation.** When working from `.agent/tasks.json`, complete exactly one task, commit, and stop. Never batch multiple tasks.
31
+
28
32
  ## Code Style
29
33
 
30
34
  1. Prefer writing clear code and use inline comments sparingly
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # A Ralph Wiggum Loop implementation that works™
2
2
 
3
- [![@pageai/ralph-loop version](https://img.shields.io/npm/v/@pageai/ralph-loop?label=npm&style=flat)](https://github.com/pageai-pro/ralph-loop)
3
+ [![@pageai/ralph-loop version](https://img.shields.io/npm/v/@pageai/ralph-loop?label=npm&style=flat)](https://www.npmjs.com/package/@pageai/ralph-loop)
4
4
 
5
5
  Ralph is a long-running AI agent loop. Ralph automates software development tasks by iteratively working through a task list until completion. This allows for long running agent loops, effectively enabling AI to code for days at a time.
6
6
 
@@ -34,6 +34,8 @@ This is an implementation that actually works, containing a hackable script so y
34
34
  - [Vitest configuration](#vitest-configuration)
35
35
  - [Running with a different agentic CLI](#running-with-a-different-agentic-cli)
36
36
  - [Starting from scratch](#starting-from-scratch)
37
+ - [Debugging](#debugging)
38
+ - [How to inspect the sandbox and debug](#how-to-inspect-the-sandbox-and-debug)
37
39
  - [License](#license)
38
40
 
39
41
  ---------------------------------
@@ -266,7 +268,7 @@ To do so, you need to continue using the `prd-creator` skill to update the PRD a
266
268
  For example:
267
269
 
268
270
  ```
269
- I would like to expand the PRD / tasks with these tasks:
271
+ I would like to expand the PRD. Use the prd-creator skill to create these tasks:
270
272
 
271
273
  - there is a bug in X that should be fixed by doing Y
272
274
  - create a new feature that implement Z
@@ -438,6 +440,38 @@ npm i @vitejs/plugin-react @testing-library/dom @testing-library/jest-dom @testi
438
440
 
439
441
  It is recommended that you add skills for your specific language and framework. See [skills.sh](https://skills.sh) to discover existing skills.
440
442
 
443
+ ### Debugging
444
+
445
+ ## How to inspect the sandbox and debug
446
+
447
+ You might be wondering... if this is not a Docker container, how can you see what's going on inside Docker!?
448
+ How to debug/install things?
449
+
450
+ That's quite straightforward.
451
+
452
+ You first need to run:
453
+
454
+ ```bash
455
+ docker sandbox list
456
+ ```
457
+
458
+ Note down the name of the sandbox, e.g. `claude-ralph-loop`.
459
+
460
+ And then you can run bash into any of the sandboxes like so:
461
+ ```bash
462
+ docker sandbox exec -it <sandbox-name> bash # e.g. docker sandbox exec -it claude-ralph-loop bash
463
+ ```
464
+
465
+ And you have full control over the sandbox, just like a regular container. You can install packages, run commands, etc.
466
+
467
+ You can also also run Claude Code inside the sandbox (make sure to navigate to the project directory first).
468
+
469
+ ```bash
470
+ docker sandbox exec -it <sandbox-name> bash
471
+ cd /path/to/your/project # this is the same path as the path in the root machine, e.g. /Users/your-username/Documents/your-project
472
+ claude
473
+ ```
474
+
441
475
  ## License
442
476
 
443
477
  MIT
package/bin/cli.js CHANGED
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const display = require('./lib/display');
12
12
  const { copyFile, copyDir, mergeDir, exists, ensureDir } = require('./lib/copy');
13
+ const { mergeSettings } = require('./lib/settings');
13
14
  const { isGitRepo, initGitRepo } = require('./lib/git');
14
15
  const { isShadcnProject, installAllComponents } = require('./lib/shadcn');
15
16
  const { setupPlaywright } = require('./lib/playwright');
@@ -124,7 +125,7 @@ const DIRS_TO_MERGE = [
124
125
 
125
126
  // Other files in these directories to copy (with exclusions)
126
127
  const EXTRA_DIR_FILES = [
127
- { dir: '.claude', exclude: ['settings.local.json', 'skills', 'agents', 'commands', 'hooks'] },
128
+ { dir: '.claude', exclude: ['settings.local.json', 'settings.json', 'skills', 'agents', 'commands', 'hooks'] },
128
129
  ];
129
130
 
130
131
  async function main() {
@@ -319,6 +320,21 @@ async function main() {
319
320
  }
320
321
  }
321
322
 
323
+ // Merge .claude/settings.json (preserve user settings, add hooks)
324
+ console.log();
325
+ display.printStep('🔧', 'Settings');
326
+ const settingsResult = mergeSettings(
327
+ path.join(PACKAGE_ROOT, '.claude/settings.json'),
328
+ path.join(TARGET_DIR, '.claude/settings.json')
329
+ );
330
+ if (settingsResult.created) {
331
+ display.printSuccess('.claude/settings.json (created)');
332
+ } else if (settingsResult.merged) {
333
+ display.printSuccess('.claude/settings.json (merged hooks)');
334
+ } else if (settingsResult.backedUp) {
335
+ display.printWarning('.claude/settings.json (backed up malformed, replaced)');
336
+ }
337
+
322
338
  // Replace dev server URL in copied files if different from default
323
339
  if (devServerUrl !== 'http://localhost:3000') {
324
340
  const filesToPatch = [
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Settings merge module for Ralph Loop CLI
3
+ * Merges hook configurations into existing .claude/settings.json
4
+ * without overwriting user preferences
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { ensureDir, exists } = require('./copy');
10
+
11
+ /**
12
+ * Merges hook entries by event type, deduplicating by command string.
13
+ * Source hooks are only added if their commands aren't already present.
14
+ * @param {Object} existing - Existing hooks object (e.g. { PostToolUse: [...], Notification: [...] })
15
+ * @param {Object} source - Source hooks to merge in
16
+ * @returns {Object} Merged hooks
17
+ */
18
+ function mergeHooks(existing = {}, source = {}) {
19
+ const result = JSON.parse(JSON.stringify(existing));
20
+
21
+ for (const [eventType, sourceEntries] of Object.entries(source)) {
22
+ if (!Array.isArray(sourceEntries)) continue;
23
+
24
+ if (!result[eventType]) {
25
+ result[eventType] = sourceEntries;
26
+ continue;
27
+ }
28
+
29
+ const existingCommands = new Set();
30
+ for (const entry of result[eventType]) {
31
+ for (const hook of (entry.hooks || [])) {
32
+ if (hook.command) existingCommands.add(hook.command);
33
+ }
34
+ }
35
+
36
+ for (const entry of sourceEntries) {
37
+ const commands = (entry.hooks || []).map(h => h.command).filter(Boolean);
38
+ const alreadyExists = commands.some(cmd => existingCommands.has(cmd));
39
+ if (!alreadyExists) {
40
+ result[eventType].push(entry);
41
+ }
42
+ }
43
+ }
44
+
45
+ return result;
46
+ }
47
+
48
+ /**
49
+ * Merges ralph-loop's settings.json into an existing one.
50
+ * - If dest doesn't exist: copies source as-is
51
+ * - If dest has malformed JSON: backs up and replaces
52
+ * - If dest is valid: merges hooks additively, sets missing non-hooks keys
53
+ * @param {string} srcPath - Path to ralph-loop's settings.json
54
+ * @param {string} destPath - Path to target project's settings.json
55
+ * @returns {{ created: boolean, merged: boolean, backedUp: boolean }}
56
+ */
57
+ function mergeSettings(srcPath, destPath) {
58
+ if (!exists(srcPath)) {
59
+ return { created: false, merged: false, backedUp: false };
60
+ }
61
+
62
+ const sourceSettings = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
63
+ ensureDir(path.dirname(destPath));
64
+
65
+ if (!exists(destPath)) {
66
+ fs.writeFileSync(destPath, JSON.stringify(sourceSettings, null, 2) + '\n', 'utf8');
67
+ return { created: true, merged: false, backedUp: false };
68
+ }
69
+
70
+ const raw = fs.readFileSync(destPath, 'utf8');
71
+ let existingSettings;
72
+
73
+ try {
74
+ existingSettings = JSON.parse(raw);
75
+ } catch {
76
+ fs.copyFileSync(destPath, destPath + '.bak');
77
+ fs.writeFileSync(destPath, JSON.stringify(sourceSettings, null, 2) + '\n', 'utf8');
78
+ return { created: false, merged: false, backedUp: true };
79
+ }
80
+
81
+ const merged = { ...existingSettings };
82
+ for (const [key, value] of Object.entries(sourceSettings)) {
83
+ if (key === 'hooks') continue;
84
+ if (!(key in merged)) {
85
+ merged[key] = value;
86
+ }
87
+ }
88
+
89
+ merged.hooks = mergeHooks(existingSettings.hooks, sourceSettings.hooks);
90
+
91
+ fs.writeFileSync(destPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
92
+ return { created: false, merged: true, backedUp: false };
93
+ }
94
+
95
+ module.exports = {
96
+ mergeHooks,
97
+ mergeSettings,
98
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pageai/ralph-loop",
3
- "version": "1.18.0",
3
+ "version": "1.21.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/ralph.sh CHANGED
@@ -98,7 +98,7 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
98
98
  export PROMPT_CONTENT
99
99
  export DOCKER_DEFAULT_PLATFORM=linux/amd64 # Needed for Playwright.
100
100
 
101
- script -q "$OUTPUT_FILE" bash -c 'docker sandbox run claude . -- --model opus --output-format stream-json --verbose -p "$PROMPT_CONTENT"' >/dev/null 2>&1 &
101
+ script -q "$OUTPUT_FILE" bash -c 'docker sandbox run claude . -- --effort max --model opus --output-format stream-json --verbose -p "$PROMPT_CONTENT"' >/dev/null 2>&1 &
102
102
  AGENT_PID=$!
103
103
 
104
104
  # Track position in output file for incremental reading
@@ -113,7 +113,7 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
113
113
  # Read new content if file has grown
114
114
  if [ "$CURRENT_SIZE" -gt "$LAST_POS" ]; then
115
115
  # Read new lines
116
- tail -c +$((LAST_POS + 1)) "$OUTPUT_FILE" 2>/dev/null | while IFS= read -r line; do
116
+ while IFS= read -r line; do
117
117
  if [ -n "$line" ]; then
118
118
  # Parse JSON and extract text content
119
119
  parsed=$(parse_json_content "$line")
@@ -126,11 +126,11 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
126
126
  update_preview_line "$parsed"
127
127
  fi
128
128
  fi
129
- done
129
+ done < <(tail -c +$((LAST_POS + 1)) "$OUTPUT_FILE" 2>/dev/null)
130
130
  LAST_POS=$CURRENT_SIZE
131
131
  fi
132
132
  fi
133
- sleep 0.2
133
+ sleep 0.2 || true
134
134
  done
135
135
 
136
136
  wait "$AGENT_PID" || true
@@ -140,14 +140,14 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
140
140
  if [ -f "$OUTPUT_FILE" ]; then
141
141
  CURRENT_SIZE=$(stat -f%z "$OUTPUT_FILE" 2>/dev/null || stat -c%s "$OUTPUT_FILE" 2>/dev/null || echo "0")
142
142
  if [ "$CURRENT_SIZE" -gt "$LAST_POS" ]; then
143
- tail -c +$((LAST_POS + 1)) "$OUTPUT_FILE" 2>/dev/null | while IFS= read -r line; do
143
+ while IFS= read -r line; do
144
144
  if [ -n "$line" ]; then
145
145
  parsed=$(parse_json_content "$line")
146
146
  if [ -n "$parsed" ]; then
147
147
  echo "$parsed" >> "$FULL_OUTPUT_FILE"
148
148
  fi
149
149
  fi
150
- done
150
+ done < <(tail -c +$((LAST_POS + 1)) "$OUTPUT_FILE" 2>/dev/null)
151
151
  fi
152
152
  fi
153
153
 
@@ -307,7 +307,7 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
307
307
  if [ -n "$STEP_TIMES_OUTPUT" ]; then
308
308
  echo -e "${G} └──${R} $STEP_TIMES_OUTPUT"
309
309
  fi
310
- sleep 2
310
+ sleep 2 || true
311
311
  done
312
312
 
313
313
  # Calculate final elapsed time
@@ -16,13 +16,39 @@ cleanup() {
16
16
  fi
17
17
  SPINNER_PID=""
18
18
 
19
- # Kill Agent process if still running
19
+ # Kill Agent process tree if still running
20
+ # The process tree is: script -> bash -> docker sandbox run
21
+ # Killing just the script PID leaves the docker sandbox running
20
22
  if [ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null; then
23
+ # Kill child processes first (bash -> docker), then the script process
24
+ pkill -TERM -P "$AGENT_PID" 2>/dev/null || true
21
25
  kill "$AGENT_PID" 2>/dev/null || true
22
26
  wait "$AGENT_PID" 2>/dev/null || true
23
27
  fi
24
28
  AGENT_PID=""
25
29
 
30
+ # Stop all running Docker sandboxes for this workspace
31
+ # Finds sandboxes by matching the workspace path, regardless of agent prefix
32
+ if command -v docker &>/dev/null && command -v jq &>/dev/null; then
33
+ local sandboxes
34
+ sandboxes=$(docker sandbox ls --json 2>/dev/null \
35
+ | jq -r --arg ws "$SCRIPT_DIR" '(.vms // .sandboxes // [])[] | select(.status == "running" and (.workspaces[] == $ws)) | .name' 2>/dev/null)
36
+
37
+ if [ -n "$sandboxes" ]; then
38
+ echo -e " ${Y}📦 Stopping sandbox...${R}"
39
+ # Stop all matching sandboxes (pass all names at once)
40
+ docker sandbox stop $sandboxes 2>/dev/null &
41
+ local stop_pid=$!
42
+ # Timeout after 5 seconds to avoid hanging on exit
43
+ ( sleep 5 && kill "$stop_pid" 2>/dev/null ) &>/dev/null &
44
+ local timeout_pid=$!
45
+ if wait "$stop_pid" 2>/dev/null; then
46
+ echo -e " ${GR}✅ Sandbox stopped${R}"
47
+ fi
48
+ kill "$timeout_pid" 2>/dev/null || true
49
+ fi
50
+ fi
51
+
26
52
  # Remove temporary files
27
53
  [[ -n "$STEP_FILE" ]] && rm -f "$STEP_FILE" 2>/dev/null || true
28
54
  [[ -n "$PREVIEW_LINE_FILE" ]] && rm -f "$PREVIEW_LINE_FILE" 2>/dev/null || true
@@ -11,16 +11,42 @@ parse_json_content() {
11
11
  # For example, Claude stream-json outputs {"type":"content_block_delta","delta":{"text":"..."}}
12
12
  # or {"type":"text","text":"..."} etc.
13
13
 
14
- # Extract text from delta
15
- local text=$(echo "$json_line" | grep -o '"text":"[^"]*"' | head -1 | sed 's/"text":"//;s/"$//')
14
+ # Extract text from delta (use jq if available for proper escaped quote handling)
15
+ local text=""
16
+ if command -v jq &>/dev/null; then
17
+ text=$(echo "$json_line" | jq -r '.delta.text // .text // empty' 2>/dev/null)
18
+ else
19
+ text=$(echo "$json_line" | grep -o '"text":"[^"]*"' | head -1 | LC_ALL=C sed 's/"text":"//;s/"$//' 2>/dev/null)
20
+ fi
16
21
  if [ -n "$text" ]; then
17
- # Unescape common JSON escapes
18
- # JSON \n -> actual newline, \t -> actual tab, etc.
19
- text=$(echo "$text" | sed 's/\\n/\'$'\n''/g; s/\\t/\'$'\t''/g; s/\\"/"/g; s/\\\\/\\/g')
22
+ # Unescape common JSON escapes (only needed for grep fallback, jq handles this)
23
+ if ! command -v jq &>/dev/null; then
24
+ text=$(echo "$text" | LC_ALL=C sed 's/\\n/\'$'\n''/g; s/\\t/\'$'\t''/g; s/\\"/"/g; s/\\\\/\\/g' 2>/dev/null)
25
+ fi
20
26
  echo "$text"
21
27
  return
22
28
  fi
23
29
 
30
+ # Extract tool_use events (have "name" but no "text")
31
+ # Produces strings like "Read file_path=/src/main.ts" or "Bash command=npm test"
32
+ # that detect_step() patterns can match
33
+ local tool_name=$(echo "$json_line" | grep -o '"name":"[^"]*"' | head -1 | LC_ALL=C sed 's/"name":"//;s/"$//' 2>/dev/null)
34
+ if [ -n "$tool_name" ]; then
35
+ local file_path=$(echo "$json_line" | grep -o '"file_path":"[^"]*"' | head -1 | LC_ALL=C sed 's/"file_path":"//;s/"$//' 2>/dev/null)
36
+ local pattern=$(echo "$json_line" | grep -o '"pattern":"[^"]*"' | head -1 | LC_ALL=C sed 's/"pattern":"//;s/"$//' 2>/dev/null)
37
+ local cmd=$(echo "$json_line" | grep -o '"command":"[^"]*"' | head -1 | LC_ALL=C sed 's/"command":"//;s/"$//' 2>/dev/null)
38
+ if [ -n "$file_path" ]; then
39
+ echo "$tool_name file_path=$file_path"
40
+ elif [ -n "$pattern" ]; then
41
+ echo "$tool_name pattern=$pattern"
42
+ elif [ -n "$cmd" ]; then
43
+ echo "$tool_name command=$cmd"
44
+ else
45
+ echo "$tool_name"
46
+ fi
47
+ return
48
+ fi
49
+
24
50
  # If it doesn't look like JSON, return as-is
25
51
  if ! echo "$json_line" | grep -q '^{'; then
26
52
  echo "$json_line"
@@ -44,7 +70,7 @@ strip_ansi() {
44
70
  # 0x1C-0x1F (FS through US) - excludes ESC (0x1B) for sed to process
45
71
  # sed handles: ESC sequences, OSC-like patterns, caret notation
46
72
  # Note: OSC pattern uses ^ anchor but also handles after caret removal via second pass
47
- echo "$input" | sed \
73
+ echo "$input" | LC_ALL=C sed \
48
74
  -e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
49
75
  -e 's/\x1b\][^\x07]*\x07//g' \
50
76
  -e 's/\x1b\][^\x1b]*\x1b\\//g' \
@@ -53,9 +79,9 @@ strip_ansi() {
53
79
  -e 's/\x1b.//g' \
54
80
  -e 's/\^[][A-Z@\\^_]//g' \
55
81
  -e 's/^0;[^]]*]//g' \
56
- -e 's/<u0;//g' \
57
- | tr -d '\000-\010\013\014\016-\032\034-\037' \
58
- | sed -e 's/^0;[^]]*]//g'
82
+ -e 's/<u0;//g' 2>/dev/null \
83
+ | LC_ALL=C tr -d '\000-\010\013\014\016-\032\034-\037' 2>/dev/null \
84
+ | LC_ALL=C sed -e 's/^0;[^]]*]//g' 2>/dev/null
59
85
  }
60
86
 
61
87
  # Strip ANSI from a file and write to output file
@@ -68,7 +94,7 @@ strip_ansi_file() {
68
94
  # Then tr removes remaining control characters (excluding newline 0x0A, CR 0x0D)
69
95
  # tr range excludes ESC (0x1B = octal 033) which sed already handled
70
96
  # Final sed pass cleans up OSC patterns that were hidden by control chars
71
- sed \
97
+ LC_ALL=C sed \
72
98
  -e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
73
99
  -e 's/\x1b\][^\x07]*\x07//g' \
74
100
  -e 's/\x1b\][^\x1b]*\x1b\\//g' \
@@ -78,8 +104,8 @@ strip_ansi_file() {
78
104
  -e 's/\^[][A-Z@\\^_]//g' \
79
105
  -e 's/^0;[^]]*]//g' \
80
106
  -e 's/<u0;//g' \
81
- "$input_file" | tr -d '\000-\010\013\014\016-\032\034-\037' \
82
- | sed -e 's/^0;[^]]*]//g' \
107
+ "$input_file" 2>/dev/null | LC_ALL=C tr -d '\000-\010\013\014\016-\032\034-\037' 2>/dev/null \
108
+ | LC_ALL=C sed -e 's/^0;[^]]*]//g' 2>/dev/null \
83
109
  > "$output_file"
84
110
  }
85
111
 
@@ -60,7 +60,7 @@ update_spinner_step() {
60
60
  echo "$detected" > "$STEP_FILE"
61
61
 
62
62
  # Clean step name (remove trailing spaces) for timing tracking
63
- local clean_step=$(echo "$detected" | sed 's/ *$//')
63
+ local clean_step=$(echo "$detected" | LC_ALL=C sed 's/ *$//' 2>/dev/null)
64
64
 
65
65
  # Record timing if step changed
66
66
  if [ "$clean_step" != "$CURRENT_STEP_NAME" ]; then
@@ -77,7 +77,7 @@ update_preview_line() {
77
77
  [[ "$line" =~ ^[[:space:]]*$ ]] && return
78
78
  # Sanitize: replace newlines/tabs with spaces, collapse multiple spaces
79
79
  # This prevents multi-line content from breaking cursor positioning
80
- line=$(echo "$line" | tr '\n\t\r' ' ' | sed 's/ */ /g; s/^ *//; s/ *$//')
80
+ line=$(echo "$line" | LC_ALL=C tr '\n\t\r' ' ' 2>/dev/null | LC_ALL=C sed 's/ */ /g; s/^ *//; s/ *$//' 2>/dev/null | head -1)
81
81
  # Skip if sanitization resulted in empty string
82
82
  [ -z "$line" ] && return
83
83
  # Write to preview file (spinner reads this)
@@ -224,6 +224,8 @@ detect_step() {
224
224
  echo "Implementing"
225
225
  elif echo "$line" | grep -qiE "(compiling|bundling|transpiling)"; then
226
226
  echo "Implementing"
227
+ elif echo "$line" | grep -qiE "Bash.*command=.*(build|compile|bundle)"; then
228
+ echo "Implementing"
227
229
 
228
230
  # COMMITTING - git operations
229
231
  elif echo "$line" | grep -qiE "(git commit|git add|committing|staged for commit)"; then
@@ -238,6 +240,8 @@ detect_step() {
238
240
  echo "Testing"
239
241
  elif echo "$line" | grep -qiE "(running|executing) (tests|test suite)"; then
240
242
  echo "Testing"
243
+ elif echo "$line" | grep -qiE "Bash.*command=.*(test|e2e|spec|jest|vitest|playwright|pytest)"; then
244
+ echo "Testing"
241
245
 
242
246
  # DEBUGGING - investigating errors, fixing issues
243
247
  elif echo "$line" | grep -qiE "(the|this) (error|issue|problem|bug) (is|seems|appears|was)"; then
@@ -254,10 +258,14 @@ detect_step() {
254
258
  # LINTING - code style and formatting
255
259
  elif echo "$line" | grep -qiE "(eslint|biome|lint|prettier|formatting|stylelint)"; then
256
260
  echo "Linting"
261
+ elif echo "$line" | grep -qiE "Bash.*command=.*(lint|eslint|biome|prettier|stylelint)"; then
262
+ echo "Linting"
257
263
 
258
264
  # TYPECHECKING - type validation
259
265
  elif echo "$line" | grep -qiE "(npm run typecheck|tsc|typescript|type.?check|mypy|pyright)"; then
260
266
  echo "Typechecking"
267
+ elif echo "$line" | grep -qiE "Bash.*command=.*(typecheck|tsc|mypy|pyright)"; then
268
+ echo "Typechecking"
261
269
 
262
270
  # WRITING TESTS - creating test files
263
271
  elif echo "$line" | grep -qiE "(\.test\.|\.spec\.|test file|writing test|adding test)"; then
@@ -272,6 +280,8 @@ detect_step() {
272
280
  echo "Installing"
273
281
  elif echo "$line" | grep -qiE "(installing|adding|updating) (dependency|dependencies|package)"; then
274
282
  echo "Installing"
283
+ elif echo "$line" | grep -qiE "Bash.*command=.*(npm install|yarn add|pnpm add|pip install|brew install)"; then
284
+ echo "Installing"
275
285
 
276
286
  # WEB RESEARCH - fetching docs, searching web
277
287
  elif echo "$line" | grep -qiE "(WebFetch|WebSearch)"; then
File without changes