@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 +12 -1
- package/.claude/hooks/one-task-guard.js +92 -0
- package/.claude/hooks/{pre-tool-use.js → post-tool-use.js} +1 -1
- package/.claude/settings.json +6 -7
- package/AGENTS.md +4 -0
- package/README.md +36 -2
- package/bin/cli.js +17 -1
- package/bin/lib/settings.js +98 -0
- package/package.json +1 -1
- package/ralph.sh +7 -7
- package/scripts/lib/cleanup.sh +27 -1
- package/scripts/lib/output.sh +38 -12
- package/scripts/lib/spinner.sh +2 -2
- package/scripts/lib/timing.sh +10 -0
- /package/.claude/{commands → hooks}/aw.md +0 -0
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, '
|
|
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');
|
package/.claude/settings.json
CHANGED
|
@@ -7,23 +7,22 @@
|
|
|
7
7
|
"enableAllProjectMcpServers": true,
|
|
8
8
|
"enabledMcpjsonServers": ["playwright", "context7", "sequential-thinking"],
|
|
9
9
|
"hooks": {
|
|
10
|
-
"
|
|
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/
|
|
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": "
|
|
21
|
+
"matcher": "Bash",
|
|
23
22
|
"hooks": [
|
|
24
23
|
{
|
|
25
24
|
"type": "command",
|
|
26
|
-
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/
|
|
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
|
-
[](https://
|
|
3
|
+
[](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
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
package/scripts/lib/cleanup.sh
CHANGED
|
@@ -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
|
package/scripts/lib/output.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
package/scripts/lib/spinner.sh
CHANGED
|
@@ -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)
|
package/scripts/lib/timing.sh
CHANGED
|
@@ -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
|