claudeboard 2.4.0 → 2.9.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/agents/board-client.js +15 -5
- package/agents/expo-health.js +311 -0
- package/agents/orchestrator.js +37 -22
- package/agents/qa.js +32 -1
- package/dashboard/index.html +192 -63
- package/dashboard/server.js +89 -35
- package/package.json +1 -1
package/agents/board-client.js
CHANGED
|
@@ -54,12 +54,20 @@ export async function addLog(taskId, message, type = "progress") {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export async function createEpic(name) {
|
|
57
|
+
// Check if epic with this name already exists for this project
|
|
58
|
+
const { data: existing } = await supabase
|
|
59
|
+
.from("cb_epics")
|
|
60
|
+
.select("id")
|
|
61
|
+
.eq("project", PROJECT)
|
|
62
|
+
.eq("name", name)
|
|
63
|
+
.single();
|
|
64
|
+
if (existing) return existing.id;
|
|
57
65
|
const { data } = await supabase.from("cb_epics").insert({ name, project: PROJECT }).select().single();
|
|
58
|
-
return data;
|
|
66
|
+
return data?.id;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
export async function createTask({ epicId, title, description, priority = "medium", type = "feature" }) {
|
|
62
|
-
const
|
|
69
|
+
export async function createTask({ epicId, title, description, priority = "medium", priorityOrder, type = "feature", status = "todo" }) {
|
|
70
|
+
const defaultOrder = { high: 1, medium: 2, low: 3 };
|
|
63
71
|
const { data } = await supabase
|
|
64
72
|
.from("cb_tasks")
|
|
65
73
|
.insert({
|
|
@@ -68,14 +76,16 @@ export async function createTask({ epicId, title, description, priority = "mediu
|
|
|
68
76
|
title,
|
|
69
77
|
description,
|
|
70
78
|
priority,
|
|
71
|
-
priority_order: priorityOrder[priority]
|
|
79
|
+
priority_order: priorityOrder ?? defaultOrder[priority] ?? 2,
|
|
72
80
|
type,
|
|
73
|
-
status
|
|
81
|
+
status,
|
|
74
82
|
})
|
|
75
83
|
.select()
|
|
76
84
|
.single();
|
|
77
85
|
return data;
|
|
78
86
|
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
79
89
|
|
|
80
90
|
/**
|
|
81
91
|
* Check if this project already has tasks in the board
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { callClaudeJSON } from "./claude-api.js";
|
|
2
|
+
import { runCommand } from "../tools/terminal.js";
|
|
3
|
+
import { createTask, createEpic, addLog } from "./board-client.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
import { createConnection } from "net";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const MAX_FIX_ATTEMPTS = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Expo Health Check Agent — runs BEFORE the development loop.
|
|
15
|
+
*
|
|
16
|
+
* Strategy:
|
|
17
|
+
* 1. Try npm install (--legacy-peer-deps, then --force if needed)
|
|
18
|
+
* 2. If install fails with ETARGET/missing version → ask Claude for fix → apply → retry
|
|
19
|
+
* 3. Start Expo, wait for Metro to be truly ready (not just port open)
|
|
20
|
+
* 4. If Metro crashes with module/dep errors → ask Claude → apply → retry
|
|
21
|
+
* 5. If still broken after MAX attempts → inject a BLOCKER task into the board
|
|
22
|
+
* so the developer agent fixes it before any other task runs
|
|
23
|
+
*
|
|
24
|
+
* Returns { ready: boolean, process: ChildProcess|null }
|
|
25
|
+
*/
|
|
26
|
+
export async function runExpoHealthCheck(projectPath, port = 8081) {
|
|
27
|
+
console.log(chalk.bold.cyan("\n[ EXPO HEALTH CHECK ]\n"));
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(path.join(projectPath, "package.json"))) {
|
|
30
|
+
console.log(chalk.dim(" No package.json — skipping"));
|
|
31
|
+
return { ready: false, process: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── 1. Install ─────────────────────────────────────────────────────────────
|
|
35
|
+
const installOk = await installDeps(projectPath);
|
|
36
|
+
if (!installOk) {
|
|
37
|
+
await injectFixTask(projectPath, "npm install fails — cannot start Expo",
|
|
38
|
+
"npm install fails with ETARGET or unresolvable version conflicts. " +
|
|
39
|
+
"Audit package.json, fix all version constraints to be compatible with " +
|
|
40
|
+
"the installed Expo SDK, then run npm install --legacy-peer-deps."
|
|
41
|
+
);
|
|
42
|
+
console.log(chalk.yellow(" ✗ Install failed — injected fix task into board\n"));
|
|
43
|
+
return { ready: false, process: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── 2. Start Expo + verify Metro is error-free ─────────────────────────────
|
|
47
|
+
for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
|
|
48
|
+
console.log(chalk.dim(` Starting Expo (attempt ${attempt}/${MAX_FIX_ATTEMPTS})...`));
|
|
49
|
+
|
|
50
|
+
const result = await tryStartExpo(projectPath, port);
|
|
51
|
+
|
|
52
|
+
if (result.ready) {
|
|
53
|
+
console.log(chalk.green(` ✓ Expo running cleanly on port ${port}\n`));
|
|
54
|
+
return { ready: true, process: result.process };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const error = result.error;
|
|
58
|
+
console.log(chalk.yellow(` ✗ Expo error: ${error?.split("\n")[0]?.slice(0, 120)}`));
|
|
59
|
+
|
|
60
|
+
if (attempt === MAX_FIX_ATTEMPTS) break;
|
|
61
|
+
|
|
62
|
+
// Ask Claude for a fix and apply it
|
|
63
|
+
const fixed = await applyExpoFix(projectPath, error);
|
|
64
|
+
if (!fixed) break;
|
|
65
|
+
console.log(chalk.dim(" Fix applied — retrying..."));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// All attempts failed — inject task so developer agent fixes it
|
|
69
|
+
const lastError = await getLastExpoError(projectPath);
|
|
70
|
+
await injectFixTask(projectPath,
|
|
71
|
+
"FIX: Expo fails to start — resolve dependency errors",
|
|
72
|
+
`Expo cannot start due to dependency errors. Fix ALL issues so the app boots without errors.\n\n` +
|
|
73
|
+
`Last error:\n${lastError}\n\n` +
|
|
74
|
+
`Steps:\n` +
|
|
75
|
+
`1. Read package.json and identify incompatible versions\n` +
|
|
76
|
+
`2. Fix react-native-worklets, react-native-reanimated and any other conflicting packages\n` +
|
|
77
|
+
`3. Run: npm install --legacy-peer-deps\n` +
|
|
78
|
+
`4. Verify with: npx expo start --web --port ${port} (should not crash)\n` +
|
|
79
|
+
`5. If imports fail (Unable to resolve module), remove or replace the problematic import`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
console.log(chalk.yellow(" ✗ Expo broken — injected fix task (will be worked on first)\n"));
|
|
83
|
+
return { ready: false, process: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── npm install with progressive fallback ─────────────────────────────────
|
|
87
|
+
async function installDeps(projectPath) {
|
|
88
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
|
|
89
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
90
|
+
|
|
91
|
+
// Step 1: legacy-peer-deps
|
|
92
|
+
console.log(chalk.dim(" npm install --legacy-peer-deps..."));
|
|
93
|
+
let result = await runCommand("npm install --legacy-peer-deps 2>&1", projectPath, 180000);
|
|
94
|
+
if (!hasFatalNpmError(result.stdout)) {
|
|
95
|
+
console.log(chalk.dim(" ✓ Dependencies installed"));
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const error1 = result.stdout;
|
|
100
|
+
console.log(chalk.yellow(` ⚠ Install error: ${getFatalError(error1)}`));
|
|
101
|
+
|
|
102
|
+
// Step 2: ask Claude to fix version conflicts
|
|
103
|
+
console.log(chalk.dim(" Asking Claude to resolve version conflicts..."));
|
|
104
|
+
const fix = await askClaudeForInstallFix(deps, error1);
|
|
105
|
+
if (fix?.commands?.length) {
|
|
106
|
+
console.log(chalk.dim(` Diagnosis: ${fix.diagnosis}`));
|
|
107
|
+
for (const cmd of fix.commands) {
|
|
108
|
+
console.log(chalk.dim(` → ${cmd}`));
|
|
109
|
+
await runCommand(cmd + " 2>&1", projectPath, 180000);
|
|
110
|
+
}
|
|
111
|
+
// Retry install
|
|
112
|
+
result = await runCommand("npm install --legacy-peer-deps 2>&1", projectPath, 180000);
|
|
113
|
+
if (!hasFatalNpmError(result.stdout)) {
|
|
114
|
+
console.log(chalk.dim(" ✓ Dependencies installed after fix"));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Step 3: --force (last resort)
|
|
120
|
+
console.log(chalk.dim(" Retrying with --force..."));
|
|
121
|
+
result = await runCommand("npm install --force 2>&1", projectPath, 180000);
|
|
122
|
+
if (!hasFatalNpmError(result.stdout)) {
|
|
123
|
+
console.log(chalk.dim(" ✓ Dependencies installed (--force)"));
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Start Expo and wait until Metro is TRULY ready (no errors) ─────────────
|
|
131
|
+
// We wait up to 60s. Success = "Metro waiting on" AND no error lines.
|
|
132
|
+
// Failure = any "Unable to resolve module", "Cannot find module", etc.
|
|
133
|
+
async function tryStartExpo(projectPath, port) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
let output = "";
|
|
136
|
+
let resolved = false;
|
|
137
|
+
let proc = null;
|
|
138
|
+
|
|
139
|
+
const done = (ready, error) => {
|
|
140
|
+
if (resolved) return;
|
|
141
|
+
resolved = true;
|
|
142
|
+
if (!ready && proc) { try { proc.kill(); } catch {} proc = null; }
|
|
143
|
+
// Save error for later
|
|
144
|
+
if (!ready && error) saveLastError(projectPath, error);
|
|
145
|
+
resolve({ ready, process: proc, error });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const timeout = setTimeout(() =>
|
|
149
|
+
done(false, `Expo did not become ready within 60s.\n${output.slice(-800)}`), 60000);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const { spawn } = require("child_process");
|
|
153
|
+
proc = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
|
|
154
|
+
cwd: projectPath,
|
|
155
|
+
env: { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1", EXPO_NO_DOTENV: "0" },
|
|
156
|
+
stdio: "pipe",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const checkError = (text) => {
|
|
160
|
+
if (
|
|
161
|
+
text.includes("Unable to resolve module") ||
|
|
162
|
+
text.includes("Cannot find module") ||
|
|
163
|
+
text.includes("Error: Unable") ||
|
|
164
|
+
text.includes("Module not found") ||
|
|
165
|
+
text.includes("SyntaxError") ||
|
|
166
|
+
text.includes("ENOENT: no such file")
|
|
167
|
+
) {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
setTimeout(() => done(false, output.slice(-2000)), 1500); // collect full error
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
proc.stdout.on("data", (d) => {
|
|
174
|
+
const text = d.toString();
|
|
175
|
+
output += text;
|
|
176
|
+
// Metro ready signal — but wait 3s to make sure no errors follow
|
|
177
|
+
if (
|
|
178
|
+
text.includes("Metro waiting on") ||
|
|
179
|
+
text.includes(`http://localhost:${port}`) ||
|
|
180
|
+
text.includes("Bundling complete") ||
|
|
181
|
+
text.includes("Web is waiting on")
|
|
182
|
+
) {
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
// If we haven't already failed, declare success
|
|
185
|
+
if (!resolved) { clearTimeout(timeout); done(true, null); }
|
|
186
|
+
}, 3000);
|
|
187
|
+
}
|
|
188
|
+
checkError(text);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
proc.stderr.on("data", (d) => {
|
|
192
|
+
const text = d.toString();
|
|
193
|
+
output += text;
|
|
194
|
+
checkError(text);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
proc.on("close", (code) => {
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
if (!resolved) done(false, `Expo exited (code ${code})\n${output.slice(-1000)}`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
} catch (e) {
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
done(false, e.message);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Ask Claude how to fix a runtime Expo error ────────────────────────────
|
|
210
|
+
async function applyExpoFix(projectPath, errorText) {
|
|
211
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
|
|
212
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
213
|
+
|
|
214
|
+
let verdict;
|
|
215
|
+
try {
|
|
216
|
+
verdict = await callClaudeJSON(
|
|
217
|
+
"You are a React Native / Expo dependency expert. Respond only with valid JSON.",
|
|
218
|
+
`Expo failed to start with this error:
|
|
219
|
+
\`\`\`
|
|
220
|
+
${errorText}
|
|
221
|
+
\`\`\`
|
|
222
|
+
|
|
223
|
+
Current dependencies:
|
|
224
|
+
${JSON.stringify(deps, null, 2)}
|
|
225
|
+
|
|
226
|
+
Provide the exact commands to fix this.
|
|
227
|
+
For "Unable to resolve module X from file Y": the fix is usually to install the missing package or fix its version.
|
|
228
|
+
For react-native-worklets version conflict: install react-native-worklets@latest.
|
|
229
|
+
|
|
230
|
+
Respond with JSON:
|
|
231
|
+
{
|
|
232
|
+
"diagnosis": "one sentence",
|
|
233
|
+
"commands": ["npm install ...", "..."],
|
|
234
|
+
"confidence": 0-100
|
|
235
|
+
}`
|
|
236
|
+
);
|
|
237
|
+
} catch { return false; }
|
|
238
|
+
|
|
239
|
+
if (!verdict?.commands?.length || verdict.confidence < 30) return false;
|
|
240
|
+
|
|
241
|
+
console.log(chalk.dim(` Diagnosis: ${verdict.diagnosis}`));
|
|
242
|
+
for (const cmd of verdict.commands) {
|
|
243
|
+
console.log(chalk.dim(` → ${cmd}`));
|
|
244
|
+
await runCommand(cmd + " 2>&1", projectPath, 120000);
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Ask Claude how to fix npm install failures ────────────────────────────
|
|
250
|
+
async function askClaudeForInstallFix(deps, errorText) {
|
|
251
|
+
try {
|
|
252
|
+
return await callClaudeJSON(
|
|
253
|
+
"You are a React Native dependency expert. Respond only with valid JSON.",
|
|
254
|
+
`npm install failed:
|
|
255
|
+
${errorText}
|
|
256
|
+
|
|
257
|
+
Dependencies: ${JSON.stringify(deps, null, 2)}
|
|
258
|
+
|
|
259
|
+
Provide exact commands to resolve the version conflict.
|
|
260
|
+
For ETARGET (version not found): suggest the nearest valid version.
|
|
261
|
+
For peer dep conflicts: suggest compatible versions for all conflicting packages.
|
|
262
|
+
|
|
263
|
+
JSON: { "diagnosis": "...", "commands": ["..."], "confidence": 0-100 }`
|
|
264
|
+
);
|
|
265
|
+
} catch { return null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Create a high-priority fix task in the board ──────────────────────────
|
|
269
|
+
async function injectFixTask(projectPath, title, description) {
|
|
270
|
+
try {
|
|
271
|
+
// Make sure the epic exists
|
|
272
|
+
const epicId = await createEpic("⚠ Expo Fix Required", "expo-fix");
|
|
273
|
+
await createTask({
|
|
274
|
+
epicId,
|
|
275
|
+
title,
|
|
276
|
+
description,
|
|
277
|
+
priority: "high",
|
|
278
|
+
priorityOrder: 0, // Run FIRST
|
|
279
|
+
type: "bug",
|
|
280
|
+
status: "todo",
|
|
281
|
+
});
|
|
282
|
+
console.log(chalk.yellow(` ➕ Injected task: "${title}"`));
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.log(chalk.dim(` Could not inject task: ${e.message}`));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Save/read last error to disk for later reference ─────────────────────
|
|
289
|
+
function saveLastError(projectPath, error) {
|
|
290
|
+
try {
|
|
291
|
+
fs.writeFileSync(path.join(projectPath, ".claudeboard-expo-error.txt"), error, "utf8");
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function getLastExpoError(projectPath) {
|
|
296
|
+
try {
|
|
297
|
+
const f = path.join(projectPath, ".claudeboard-expo-error.txt");
|
|
298
|
+
return fs.existsSync(f) ? fs.readFileSync(f, "utf8").slice(-1500) : "Unknown error";
|
|
299
|
+
} catch { return "Unknown error"; }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
303
|
+
function hasFatalNpmError(output) {
|
|
304
|
+
return output.includes("npm error code ETARGET") ||
|
|
305
|
+
output.includes("notarget No matching version") ||
|
|
306
|
+
(output.includes("npm error") && output.includes("ERESOLVE") && output.includes("unable to resolve"));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getFatalError(output) {
|
|
310
|
+
return output.split("\n").find(l => l.includes("npm error") && !l.includes("peer")) || output.slice(-200);
|
|
311
|
+
}
|
package/agents/orchestrator.js
CHANGED
|
@@ -5,6 +5,18 @@ import path from "path";
|
|
|
5
5
|
import { runArchitectAgent } from "./architect.js";
|
|
6
6
|
import { runDeveloperAgent } from "./developer.js";
|
|
7
7
|
import { runQAAgent, runFullAppQA } from "./qa.js";
|
|
8
|
+
import { runExpoHealthCheck } from "./expo-health.js";
|
|
9
|
+
import { createConnection } from "net";
|
|
10
|
+
|
|
11
|
+
function isPortOpen(port) {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
const sock = createConnection({ port, host: "127.0.0.1" });
|
|
14
|
+
sock.setTimeout(600);
|
|
15
|
+
sock.once("connect", () => { sock.destroy(); resolve(true); });
|
|
16
|
+
sock.once("error", () => resolve(false));
|
|
17
|
+
sock.once("timeout", () => resolve(false));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
8
20
|
import {
|
|
9
21
|
initBoard,
|
|
10
22
|
getNextTask,
|
|
@@ -84,28 +96,11 @@ export async function runOrchestrator(config) {
|
|
|
84
96
|
const logFile = path.join(projectPath, ".claudeboard-logs.txt");
|
|
85
97
|
const logStream = fs.createWriteStream(logFile, { flags: "a" });
|
|
86
98
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expoProcess = startProcess(
|
|
93
|
-
"npx",
|
|
94
|
-
["expo", "start", "--web", "--port", String(expoPort)],
|
|
95
|
-
projectPath,
|
|
96
|
-
(log) => {
|
|
97
|
-
logStream.write(log);
|
|
98
|
-
if (log.includes("Metro waiting") || log.includes("localhost:" + expoPort)) {
|
|
99
|
-
expoReady = true;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
// Wait up to 30s for expo to start
|
|
105
|
-
await waitForPort(expoPort, 30000);
|
|
106
|
-
expoReady = true;
|
|
107
|
-
console.log(chalk.dim(` Expo Web running at http://localhost:${expoPort}\n`));
|
|
108
|
-
}
|
|
99
|
+
// ── EXPO HEALTH CHECK: install deps + start + auto-fix errors ─────────────
|
|
100
|
+
// Runs before development loop so the app is visible from task #1
|
|
101
|
+
const expoHealth = await runExpoHealthCheck(projectPath, expoPort);
|
|
102
|
+
expoReady = expoHealth.ready;
|
|
103
|
+
expoProcess = expoHealth.process;
|
|
109
104
|
|
|
110
105
|
// ── PHASE 2: DEVELOPMENT LOOP ─────────────────────────────────────────────
|
|
111
106
|
console.log(chalk.bold.cyan("[ PHASE 2: DEVELOPMENT ]\n"));
|
|
@@ -145,6 +140,26 @@ export async function runOrchestrator(config) {
|
|
|
145
140
|
|
|
146
141
|
consecutiveFailures = 0;
|
|
147
142
|
|
|
143
|
+
// ── If this was an Expo fix task, clear the error and re-health-check ────
|
|
144
|
+
if (task.title?.toLowerCase().includes("expo") && task.type === "bug") {
|
|
145
|
+
try { fs.unlinkSync(path.join(projectPath, ".claudeboard-expo-error.txt")); } catch {}
|
|
146
|
+
console.log(chalk.dim(" Expo fix completed — re-running health check..."));
|
|
147
|
+
const recheck = await runExpoHealthCheck(projectPath, expoPort);
|
|
148
|
+
expoReady = recheck.ready;
|
|
149
|
+
expoProcess = recheck.process;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Re-check Expo health if it's supposed to be running but isn't ────────
|
|
153
|
+
if (expoReady) {
|
|
154
|
+
const portOpen = await isPortOpen(expoPort);
|
|
155
|
+
if (!portOpen) {
|
|
156
|
+
console.log(chalk.yellow(" ⚠ Expo seems to have crashed — running health check..."));
|
|
157
|
+
const recheck = await runExpoHealthCheck(projectPath, expoPort);
|
|
158
|
+
expoReady = recheck.ready;
|
|
159
|
+
expoProcess = recheck.process;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
148
163
|
// Run QA agent (only if expo is running and this is a UI/feature task)
|
|
149
164
|
const shouldRunQA = expoReady && ["feature", "bug"].includes(task.type);
|
|
150
165
|
|
package/agents/qa.js
CHANGED
|
@@ -33,11 +33,22 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// ── 3. Screenshot (only if Expo is running)
|
|
36
|
+
// ── 3. Screenshot + Metro error check (only if Expo is running) ───────────
|
|
37
37
|
let visualVerdict = null;
|
|
38
38
|
const expoRunning = await isPortOpen(expoPort);
|
|
39
39
|
|
|
40
40
|
if (expoRunning) {
|
|
41
|
+
// Verify Metro is actually serving without errors (not just port open)
|
|
42
|
+
const metroCheck = await checkMetroHealth(expoPort, projectPath);
|
|
43
|
+
if (!metroCheck.healthy) {
|
|
44
|
+
await addLog(task.id, `Metro error detected: ${metroCheck.error?.slice(0, 100)}`, "error");
|
|
45
|
+
return {
|
|
46
|
+
passed: false,
|
|
47
|
+
issues: [`Expo/Metro has active errors: ${metroCheck.error?.slice(0, 200)}`],
|
|
48
|
+
fixInstructions: `Metro is crashing with this error:\n${metroCheck.error}\n\nFix this error so the app loads without crashing on the device.`,
|
|
49
|
+
screenshotPath: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
41
52
|
await addLog(task.id, "Taking screenshot...", "progress");
|
|
42
53
|
const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
|
|
43
54
|
const screenshot = await screenshotExpoWeb(expoPort, screenshotDir);
|
|
@@ -227,3 +238,23 @@ async function isPortOpen(port) {
|
|
|
227
238
|
});
|
|
228
239
|
} catch { return false; }
|
|
229
240
|
}
|
|
241
|
+
|
|
242
|
+
// Check Metro is serving without errors by fetching the bundle status
|
|
243
|
+
async function checkMetroHealth(port, projectPath) {
|
|
244
|
+
try {
|
|
245
|
+
// Read the expo error file if it exists (written by health check)
|
|
246
|
+
const errorFile = path.join(projectPath, ".claudeboard-expo-error.txt");
|
|
247
|
+
if (fs.existsSync(errorFile)) {
|
|
248
|
+
const errorContent = fs.readFileSync(errorFile, "utf8");
|
|
249
|
+
// Only fail if error is recent (file modified in last 5 min)
|
|
250
|
+
const stat = fs.statSync(errorFile);
|
|
251
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
252
|
+
if (ageMs < 5 * 60 * 1000 && errorContent.includes("Unable to resolve")) {
|
|
253
|
+
return { healthy: false, error: errorContent.slice(-800) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { healthy: true };
|
|
257
|
+
} catch {
|
|
258
|
+
return { healthy: true }; // Don't fail if we can't check
|
|
259
|
+
}
|
|
260
|
+
}
|
package/dashboard/index.html
CHANGED
|
@@ -801,6 +801,31 @@
|
|
|
801
801
|
/* adjust board to not overlap toolbar */
|
|
802
802
|
.board-wrap { padding-bottom: 40px; }
|
|
803
803
|
|
|
804
|
+
/* ── LANGUAGE SELECTOR ── */
|
|
805
|
+
.lang-selector {
|
|
806
|
+
display: flex;
|
|
807
|
+
gap: 2px;
|
|
808
|
+
background: rgba(255,255,255,0.05);
|
|
809
|
+
border: 1px solid var(--border);
|
|
810
|
+
border-radius: 6px;
|
|
811
|
+
padding: 2px;
|
|
812
|
+
}
|
|
813
|
+
.lang-btn {
|
|
814
|
+
background: none;
|
|
815
|
+
border: none;
|
|
816
|
+
color: var(--muted);
|
|
817
|
+
font-family: var(--mono);
|
|
818
|
+
font-size: 10px;
|
|
819
|
+
font-weight: 700;
|
|
820
|
+
padding: 3px 8px;
|
|
821
|
+
border-radius: 4px;
|
|
822
|
+
cursor: pointer;
|
|
823
|
+
letter-spacing: 0.05em;
|
|
824
|
+
transition: all 0.15s;
|
|
825
|
+
}
|
|
826
|
+
.lang-btn:hover { color: var(--text); }
|
|
827
|
+
.lang-btn.active { background: var(--accent); color: #fff; }
|
|
828
|
+
|
|
804
829
|
/* ── RETRY BUTTON on failed cards ── */
|
|
805
830
|
.card-retry-btn {
|
|
806
831
|
margin-top: 10px;
|
|
@@ -848,10 +873,10 @@
|
|
|
848
873
|
<div class="project-badge" id="projectName">—</div>
|
|
849
874
|
|
|
850
875
|
<div class="header-stats">
|
|
851
|
-
<div class="hstat todo"><div class="hstat-dot"></div><span id="statTodo">0</span> todo</div>
|
|
852
|
-
<div class="hstat prog"><div class="hstat-dot"></div><span id="statProg">0</span> running</div>
|
|
853
|
-
<div class="hstat done"><div class="hstat-dot"></div><span id="statDone">0</span> done</div>
|
|
854
|
-
<div class="hstat err"><div class="hstat-dot"></div><span id="statErr">0</span> failed</div>
|
|
876
|
+
<div class="hstat todo"><div class="hstat-dot"></div><span id="statTodo">0</span> <span data-i18n="todo">todo</span></div>
|
|
877
|
+
<div class="hstat prog"><div class="hstat-dot"></div><span id="statProg">0</span> <span data-i18n="running">running</span></div>
|
|
878
|
+
<div class="hstat done"><div class="hstat-dot"></div><span id="statDone">0</span> <span data-i18n="done">done</span></div>
|
|
879
|
+
<div class="hstat err"><div class="hstat-dot"></div><span id="statErr">0</span> <span data-i18n="failed">failed</span></div>
|
|
855
880
|
</div>
|
|
856
881
|
|
|
857
882
|
<div class="header-right">
|
|
@@ -861,16 +886,21 @@
|
|
|
861
886
|
<div class="progress-pct" id="progressPct">0%</div>
|
|
862
887
|
<div class="ws-badge">
|
|
863
888
|
<div class="ws-dot" id="wsDot"></div>
|
|
864
|
-
<span id="wsLabel">connecting</span>
|
|
889
|
+
<span id="wsLabel" data-i18n="connecting">connecting</span>
|
|
865
890
|
</div>
|
|
866
|
-
|
|
891
|
+
<!-- Language selector -->
|
|
892
|
+
<div class="lang-selector">
|
|
893
|
+
<button class="lang-btn active" id="lang-en" onclick="setLang('en')">EN</button>
|
|
894
|
+
<button class="lang-btn" id="lang-es" onclick="setLang('es')">ES</button>
|
|
895
|
+
</div>
|
|
896
|
+
<button class="btn btn-primary" onclick="openModal()" data-i18n="addTask">+ Add Task</button>
|
|
867
897
|
</div>
|
|
868
898
|
</div>
|
|
869
899
|
|
|
870
900
|
<!-- RUNNING BAR -->
|
|
871
901
|
<div class="running-bar" id="runningBar">
|
|
872
902
|
<div class="running-spinner"></div>
|
|
873
|
-
<span class="running-label">Agent working →</span>
|
|
903
|
+
<span class="running-label" data-i18n="agentWorking">Agent working →</span>
|
|
874
904
|
<span class="running-title" id="runningTitle">—</span>
|
|
875
905
|
</div>
|
|
876
906
|
|
|
@@ -884,7 +914,7 @@
|
|
|
884
914
|
<div class="column col-todo" id="col-todo">
|
|
885
915
|
<div class="column-header">
|
|
886
916
|
<div class="column-dot"></div>
|
|
887
|
-
<span class="column-title">To Do</span>
|
|
917
|
+
<span class="column-title" data-i18n="colTodo">To Do</span>
|
|
888
918
|
<span class="column-count" id="cnt-todo">0</span>
|
|
889
919
|
</div>
|
|
890
920
|
<div class="column-body" id="body-todo" ondragover="onDragOver(event,'todo')" ondrop="onDrop(event,'todo')" ondragleave="onDragLeave(event)"></div>
|
|
@@ -894,7 +924,7 @@
|
|
|
894
924
|
<div class="column col-prog" id="col-prog">
|
|
895
925
|
<div class="column-header">
|
|
896
926
|
<div class="column-dot"></div>
|
|
897
|
-
<span class="column-title">In Progress</span>
|
|
927
|
+
<span class="column-title" data-i18n="colProg">In Progress</span>
|
|
898
928
|
<span class="column-count" id="cnt-prog">0</span>
|
|
899
929
|
</div>
|
|
900
930
|
<div class="column-body" id="body-prog" ondragover="onDragOver(event,'in_progress')" ondrop="onDrop(event,'in_progress')" ondragleave="onDragLeave(event)"></div>
|
|
@@ -904,7 +934,7 @@
|
|
|
904
934
|
<div class="column col-done" id="col-done">
|
|
905
935
|
<div class="column-header">
|
|
906
936
|
<div class="column-dot"></div>
|
|
907
|
-
<span class="column-title">Done</span>
|
|
937
|
+
<span class="column-title" data-i18n="colDone">Done</span>
|
|
908
938
|
<span class="column-count" id="cnt-done">0</span>
|
|
909
939
|
</div>
|
|
910
940
|
<div class="column-body" id="body-done" ondragover="onDragOver(event,'done')" ondrop="onDrop(event,'done')" ondragleave="onDragLeave(event)"></div>
|
|
@@ -914,7 +944,7 @@
|
|
|
914
944
|
<div class="column col-err" id="col-err">
|
|
915
945
|
<div class="column-header">
|
|
916
946
|
<div class="column-dot"></div>
|
|
917
|
-
<span class="column-title">Failed</span>
|
|
947
|
+
<span class="column-title" data-i18n="colFailed">Failed</span>
|
|
918
948
|
<span class="column-count" id="cnt-err">0</span>
|
|
919
949
|
</div>
|
|
920
950
|
<div class="column-body" id="body-err" ondragover="onDragOver(event,'error')" ondrop="onDrop(event,'error')" ondragleave="onDragLeave(event)"></div>
|
|
@@ -925,17 +955,17 @@
|
|
|
925
955
|
<!-- SIDEBAR -->
|
|
926
956
|
<div class="sidebar">
|
|
927
957
|
<div class="sidebar-tabs">
|
|
928
|
-
<button class="stab active" id="tab-activity" onclick="switchTab('activity')">Activity</button>
|
|
929
|
-
<button class="stab" id="tab-detail" onclick="switchTab('detail')">Detail</button>
|
|
958
|
+
<button class="stab active" id="tab-activity" onclick="switchTab('activity')" data-i18n="tabActivity">Activity</button>
|
|
959
|
+
<button class="stab" id="tab-detail" onclick="switchTab('detail')" data-i18n="tabDetail">Detail</button>
|
|
930
960
|
</div>
|
|
931
961
|
<div class="sidebar-body" id="sidebarBody">
|
|
932
962
|
<!-- Activity pane — always in DOM, shown/hidden -->
|
|
933
963
|
<div id="activityPane">
|
|
934
|
-
<div class="detail-empty">Waiting for activity...<br><br>Agents will log<br>their work here.</div>
|
|
964
|
+
<div class="detail-empty" data-i18n="activityEmpty">Waiting for activity...<br><br>Agents will log<br>their work here.</div>
|
|
935
965
|
</div>
|
|
936
966
|
<!-- Detail pane — always in DOM, shown/hidden -->
|
|
937
967
|
<div id="detailPane" style="display:none">
|
|
938
|
-
<div class="detail-empty">Click any task card<br>to see its details.</div>
|
|
968
|
+
<div class="detail-empty" data-i18n="detailEmpty">Click any task card<br>to see its details.</div>
|
|
939
969
|
</div>
|
|
940
970
|
</div>
|
|
941
971
|
</div>
|
|
@@ -945,38 +975,38 @@
|
|
|
945
975
|
<!-- ADD TASK MODAL -->
|
|
946
976
|
<div class="overlay" id="modal" onclick="if(event.target===this)closeModal()">
|
|
947
977
|
<div class="modal">
|
|
948
|
-
<div class="modal-title">Add Task</div>
|
|
978
|
+
<div class="modal-title" data-i18n="addTaskTitle">Add Task</div>
|
|
949
979
|
<div class="field">
|
|
950
|
-
<label>Title</label>
|
|
951
|
-
<input type="text" id="f-title" placeholder="
|
|
980
|
+
<label data-i18n="fieldTitle">Title</label>
|
|
981
|
+
<input type="text" id="f-title" data-i18n-placeholder="placeholderTitle">
|
|
952
982
|
</div>
|
|
953
983
|
<div class="field">
|
|
954
|
-
<label>Description</label>
|
|
955
|
-
<textarea id="f-desc" rows="3" placeholder="
|
|
984
|
+
<label data-i18n="fieldDesc">Description</label>
|
|
985
|
+
<textarea id="f-desc" rows="3" data-i18n-placeholder="placeholderDesc"></textarea>
|
|
956
986
|
</div>
|
|
957
987
|
<div class="modal-grid">
|
|
958
988
|
<div class="field">
|
|
959
|
-
<label>Priority</label>
|
|
989
|
+
<label data-i18n="fieldPriority">Priority</label>
|
|
960
990
|
<select id="f-priority">
|
|
961
|
-
<option value="high">High</option>
|
|
962
|
-
<option value="medium" selected>Medium</option>
|
|
963
|
-
<option value="low">Low</option>
|
|
991
|
+
<option value="high" data-i18n="prioHigh">High</option>
|
|
992
|
+
<option value="medium" selected data-i18n="prioMed">Medium</option>
|
|
993
|
+
<option value="low" data-i18n="prioLow">Low</option>
|
|
964
994
|
</select>
|
|
965
995
|
</div>
|
|
966
996
|
<div class="field">
|
|
967
|
-
<label>Type</label>
|
|
997
|
+
<label data-i18n="fieldType">Type</label>
|
|
968
998
|
<select id="f-type">
|
|
969
|
-
<option value="feature" selected>Feature</option>
|
|
970
|
-
<option value="bug">Bug</option>
|
|
971
|
-
<option value="config">Config</option>
|
|
972
|
-
<option value="refactor">Refactor</option>
|
|
973
|
-
<option value="test">Test</option>
|
|
999
|
+
<option value="feature" selected data-i18n="typeFeature">Feature</option>
|
|
1000
|
+
<option value="bug" data-i18n="typeBug">Bug</option>
|
|
1001
|
+
<option value="config" data-i18n="typeConfig">Config</option>
|
|
1002
|
+
<option value="refactor" data-i18n="typeRefactor">Refactor</option>
|
|
1003
|
+
<option value="test" data-i18n="typeTest">Test</option>
|
|
974
1004
|
</select>
|
|
975
1005
|
</div>
|
|
976
1006
|
</div>
|
|
977
1007
|
<div class="modal-actions">
|
|
978
|
-
<button class="btn-cancel" onclick="closeModal()">Cancel</button>
|
|
979
|
-
<button class="btn-create" onclick="submitTask()">Create Task</button>
|
|
1008
|
+
<button class="btn-cancel" onclick="closeModal()" data-i18n="cancel">Cancel</button>
|
|
1009
|
+
<button class="btn-create" onclick="submitTask()" data-i18n="createTask">Create Task</button>
|
|
980
1010
|
</div>
|
|
981
1011
|
</div>
|
|
982
1012
|
</div>
|
|
@@ -986,48 +1016,46 @@
|
|
|
986
1016
|
<div class="modal">
|
|
987
1017
|
<div class="modal-title" style="display:flex;align-items:center;gap:10px">
|
|
988
1018
|
<span style="color:var(--red)">✕</span>
|
|
989
|
-
<span>Edit & Retry Failed Task</span>
|
|
1019
|
+
<span data-i18n="retryTitle">Edit & Retry Failed Task</span>
|
|
990
1020
|
</div>
|
|
991
|
-
|
|
992
|
-
<div style="background:rgba(248,113,113,0.06);border:1px solid rgba(248,113,113,0.2);border-radius:8px;padding:10px 12px;margin-bottom:16px;font-family:var(--mono);font-size:11px;color:var(--red)" id="retryErrorLog">
|
|
1021
|
+
<div style="background:rgba(248,113,113,0.06);border:1px solid rgba(248,113,113,0.2);border-radius:8px;padding:10px 12px;margin-bottom:16px;font-family:var(--mono);font-size:11px;color:var(--red)" id="retryErrorLog" data-i18n="noErrorLog">
|
|
993
1022
|
No error log found.
|
|
994
1023
|
</div>
|
|
995
|
-
|
|
996
1024
|
<div class="field">
|
|
997
|
-
<label>Title</label>
|
|
1025
|
+
<label data-i18n="fieldTitle">Title</label>
|
|
998
1026
|
<input type="text" id="r-title">
|
|
999
1027
|
</div>
|
|
1000
1028
|
<div class="field">
|
|
1001
|
-
<label>Description</label>
|
|
1029
|
+
<label data-i18n="fieldDesc">Description</label>
|
|
1002
1030
|
<textarea id="r-desc" rows="4"></textarea>
|
|
1003
1031
|
</div>
|
|
1004
1032
|
<div class="field">
|
|
1005
|
-
<label style="color:var(--accent)">💬 Note for the agent (hint to fix the issue)</label>
|
|
1006
|
-
<textarea id="r-note" rows="3" placeholder="
|
|
1033
|
+
<label style="color:var(--accent)" data-i18n="agentNoteLabel">💬 Note for the agent (hint to fix the issue)</label>
|
|
1034
|
+
<textarea id="r-note" rows="3" data-i18n-placeholder="agentNotePlaceholder"></textarea>
|
|
1007
1035
|
</div>
|
|
1008
1036
|
<div class="modal-grid">
|
|
1009
1037
|
<div class="field">
|
|
1010
|
-
<label>Priority</label>
|
|
1038
|
+
<label data-i18n="fieldPriority">Priority</label>
|
|
1011
1039
|
<select id="r-priority">
|
|
1012
|
-
<option value="high">High</option>
|
|
1013
|
-
<option value="medium">Medium</option>
|
|
1014
|
-
<option value="low">Low</option>
|
|
1040
|
+
<option value="high" data-i18n="prioHigh">High</option>
|
|
1041
|
+
<option value="medium" data-i18n="prioMed">Medium</option>
|
|
1042
|
+
<option value="low" data-i18n="prioLow">Low</option>
|
|
1015
1043
|
</select>
|
|
1016
1044
|
</div>
|
|
1017
1045
|
<div class="field">
|
|
1018
|
-
<label>Type</label>
|
|
1046
|
+
<label data-i18n="fieldType">Type</label>
|
|
1019
1047
|
<select id="r-type">
|
|
1020
|
-
<option value="feature">Feature</option>
|
|
1021
|
-
<option value="bug">Bug</option>
|
|
1022
|
-
<option value="config">Config</option>
|
|
1023
|
-
<option value="refactor">Refactor</option>
|
|
1024
|
-
<option value="test">Test</option>
|
|
1048
|
+
<option value="feature" data-i18n="typeFeature">Feature</option>
|
|
1049
|
+
<option value="bug" data-i18n="typeBug">Bug</option>
|
|
1050
|
+
<option value="config" data-i18n="typeConfig">Config</option>
|
|
1051
|
+
<option value="refactor" data-i18n="typeRefactor">Refactor</option>
|
|
1052
|
+
<option value="test" data-i18n="typeTest">Test</option>
|
|
1025
1053
|
</select>
|
|
1026
1054
|
</div>
|
|
1027
1055
|
</div>
|
|
1028
1056
|
<div class="modal-actions">
|
|
1029
|
-
<button class="btn-cancel" onclick="closeRetry()">Cancel</button>
|
|
1030
|
-
<button class="btn-create" style="background:var(--red)" onclick="submitRetry()">↩ Retry Task</button>
|
|
1057
|
+
<button class="btn-cancel" onclick="closeRetry()" data-i18n="cancel">Cancel</button>
|
|
1058
|
+
<button class="btn-create" style="background:var(--red)" onclick="submitRetry()" data-i18n="retryBtn">↩ Retry Task</button>
|
|
1031
1059
|
</div>
|
|
1032
1060
|
</div>
|
|
1033
1061
|
</div>
|
|
@@ -1036,9 +1064,9 @@
|
|
|
1036
1064
|
<div class="bottom-toolbar">
|
|
1037
1065
|
<button class="toolbar-btn" id="expoBtn" onclick="toggleExpoPanel()">
|
|
1038
1066
|
📱 Expo
|
|
1039
|
-
<span class="expo-status-badge stopped" id="expoBadge">stopped</span>
|
|
1067
|
+
<span class="expo-status-badge stopped" id="expoBadge" data-i18n="statusStopped">stopped</span>
|
|
1040
1068
|
</button>
|
|
1041
|
-
<button class="toolbar-btn" id="termBtn" onclick="toggleTerminal()">
|
|
1069
|
+
<button class="toolbar-btn" id="termBtn" onclick="toggleTerminal()" data-i18n="terminal">
|
|
1042
1070
|
⌨️ Terminal
|
|
1043
1071
|
</button>
|
|
1044
1072
|
</div>
|
|
@@ -1047,18 +1075,18 @@
|
|
|
1047
1075
|
<div class="expo-panel" id="expoPanel">
|
|
1048
1076
|
<div class="expo-panel-header" onclick="toggleExpoPanel()">
|
|
1049
1077
|
<span class="expo-panel-title">📱 Expo Go</span>
|
|
1050
|
-
<span class="expo-status-badge stopped" id="expoPanelBadge">stopped</span>
|
|
1078
|
+
<span class="expo-status-badge stopped" id="expoPanelBadge" data-i18n="statusStopped">stopped</span>
|
|
1051
1079
|
<div style="margin-left:auto;display:flex;gap:8px">
|
|
1052
|
-
<button class="btn btn-primary" id="expoStartBtn" onclick="event.stopPropagation();startExpo()" style="font-size:11px;padding:4px 12px">Start Expo</button>
|
|
1053
|
-
<button class="btn btn-ghost" id="expoStopBtn" onclick="event.stopPropagation();stopExpo()" style="font-size:11px;padding:4px 12px;display:none">Stop</button>
|
|
1080
|
+
<button class="btn btn-primary" id="expoStartBtn" onclick="event.stopPropagation();startExpo()" style="font-size:11px;padding:4px 12px" data-i18n="startExpo">Start Expo</button>
|
|
1081
|
+
<button class="btn btn-ghost" id="expoStopBtn" onclick="event.stopPropagation();stopExpo()" style="font-size:11px;padding:4px 12px;display:none" data-i18n="stopExpo">Stop</button>
|
|
1054
1082
|
</div>
|
|
1055
1083
|
</div>
|
|
1056
1084
|
<div style="display:flex;gap:0">
|
|
1057
1085
|
<div style="flex:1">
|
|
1058
|
-
<div class="expo-logs" id="expoLogs">Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.</div>
|
|
1086
|
+
<div class="expo-logs" id="expoLogs" data-i18n="expoIdle">Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.</div>
|
|
1059
1087
|
<div class="expo-qr-wrap" id="expoUrlWrap" style="display:none">
|
|
1060
1088
|
<div>
|
|
1061
|
-
<div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-bottom:4px">SCAN WITH EXPO GO</div>
|
|
1089
|
+
<div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-bottom:4px" data-i18n="scanWith">SCAN WITH EXPO GO</div>
|
|
1062
1090
|
<div class="expo-url" id="expoUrl">—</div>
|
|
1063
1091
|
</div>
|
|
1064
1092
|
</div>
|
|
@@ -1122,7 +1150,7 @@ function connectWS() {
|
|
|
1122
1150
|
|
|
1123
1151
|
function setWS(on) {
|
|
1124
1152
|
document.getElementById('wsDot').className = 'ws-dot' + (on ? ' on' : '');
|
|
1125
|
-
document.getElementById('wsLabel').textContent = on ? 'live' : 'reconnecting';
|
|
1153
|
+
document.getElementById('wsLabel').textContent = on ? t('live') : t('reconnecting');
|
|
1126
1154
|
}
|
|
1127
1155
|
|
|
1128
1156
|
// ── DATA ─────────────────────────────────────────────────────────────────────
|
|
@@ -1218,7 +1246,7 @@ function cardHTML(task) {
|
|
|
1218
1246
|
<span class="tag ${task.type}">${task.type}</span>
|
|
1219
1247
|
${shortEpic ? `<span class="card-epic">${esc(shortEpic)}</span>` : ''}
|
|
1220
1248
|
</div>
|
|
1221
|
-
${isError ? `<button class="card-retry-btn" onclick="event.stopPropagation();openRetry('${task.id}')"
|
|
1249
|
+
${isError ? `<button class="card-retry-btn" onclick="event.stopPropagation();openRetry('${task.id}')">${t('retryCard')} Edit</button>` : ''}
|
|
1222
1250
|
</div>`;
|
|
1223
1251
|
}
|
|
1224
1252
|
|
|
@@ -1483,8 +1511,8 @@ function appendExpoLog(msg) {
|
|
|
1483
1511
|
}
|
|
1484
1512
|
|
|
1485
1513
|
function setExpoStatus(status, url) {
|
|
1486
|
-
const
|
|
1487
|
-
const label =
|
|
1514
|
+
const statusKey = 'status' + (status || 'stopped').charAt(0).toUpperCase() + (status || 'stopped').slice(1);
|
|
1515
|
+
const label = t(statusKey);
|
|
1488
1516
|
|
|
1489
1517
|
['expoBadge','expoPanelBadge'].forEach(id => {
|
|
1490
1518
|
const el = document.getElementById(id);
|
|
@@ -1602,6 +1630,100 @@ document.addEventListener('keydown', e => {
|
|
|
1602
1630
|
if ((e.metaKey || e.ctrlKey) && e.key === 'n') { e.preventDefault(); openModal(); }
|
|
1603
1631
|
});
|
|
1604
1632
|
|
|
1633
|
+
// ── I18N ──────────────────────────────────────────────────────────────────────
|
|
1634
|
+
const STRINGS = {
|
|
1635
|
+
en: {
|
|
1636
|
+
todo:'todo', running:'running', done:'done', failed:'failed',
|
|
1637
|
+
connecting:'connecting', live:'live', reconnecting:'reconnecting',
|
|
1638
|
+
addTask:'+ Add Task',
|
|
1639
|
+
agentWorking:'Agent working →',
|
|
1640
|
+
colTodo:'To Do', colProg:'In Progress', colDone:'Done', colFailed:'Failed',
|
|
1641
|
+
tabActivity:'Activity', tabDetail:'Detail',
|
|
1642
|
+
activityEmpty:'Waiting for activity...<br><br>Agents will log<br>their work here.',
|
|
1643
|
+
detailEmpty:'Click any task card<br>to see its details.',
|
|
1644
|
+
addTaskTitle:'Add Task',
|
|
1645
|
+
fieldTitle:'Title', fieldDesc:'Description', fieldPriority:'Priority', fieldType:'Type',
|
|
1646
|
+
placeholderTitle:'Implement login screen...',
|
|
1647
|
+
placeholderDesc:'Detailed description of what needs to be done...',
|
|
1648
|
+
prioHigh:'High', prioMed:'Medium', prioLow:'Low',
|
|
1649
|
+
typeFeature:'Feature', typeBug:'Bug', typeConfig:'Config', typeRefactor:'Refactor', typeTest:'Test',
|
|
1650
|
+
cancel:'Cancel', createTask:'Create Task',
|
|
1651
|
+
retryTitle:'Edit & Retry Failed Task',
|
|
1652
|
+
noErrorLog:'No error log found.',
|
|
1653
|
+
agentNoteLabel:'💬 Note for the agent (hint to fix the issue)',
|
|
1654
|
+
agentNotePlaceholder:'e.g. Use tailwind v3 not v4. The error is about missing module X...',
|
|
1655
|
+
retryBtn:'↩ Retry Task',
|
|
1656
|
+
terminal:'⌨️ Terminal',
|
|
1657
|
+
statusStopped:'stopped', statusInstalling:'installing', statusStarting:'starting',
|
|
1658
|
+
statusRunning:'running', statusError:'error',
|
|
1659
|
+
startExpo:'Start Expo', stopExpo:'Stop',
|
|
1660
|
+
expoIdle:'Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.',
|
|
1661
|
+
scanWith:'SCAN WITH EXPO GO',
|
|
1662
|
+
retryCard:'↩ Retry',
|
|
1663
|
+
},
|
|
1664
|
+
es: {
|
|
1665
|
+
todo:'pendiente', running:'en curso', done:'listo', failed:'fallido',
|
|
1666
|
+
connecting:'conectando', live:'en vivo', reconnecting:'reconectando',
|
|
1667
|
+
addTask:'+ Nueva tarea',
|
|
1668
|
+
agentWorking:'Agente trabajando →',
|
|
1669
|
+
colTodo:'Por hacer', colProg:'En progreso', colDone:'Hecho', colFailed:'Fallido',
|
|
1670
|
+
tabActivity:'Actividad', tabDetail:'Detalle',
|
|
1671
|
+
activityEmpty:'Esperando actividad...<br><br>Los agentes registrarán<br>su trabajo aquí.',
|
|
1672
|
+
detailEmpty:'Hacé clic en una tarea<br>para ver sus detalles.',
|
|
1673
|
+
addTaskTitle:'Nueva tarea',
|
|
1674
|
+
fieldTitle:'Título', fieldDesc:'Descripción', fieldPriority:'Prioridad', fieldType:'Tipo',
|
|
1675
|
+
placeholderTitle:'Implementar pantalla de login...',
|
|
1676
|
+
placeholderDesc:'Descripción detallada de lo que hay que hacer...',
|
|
1677
|
+
prioHigh:'Alta', prioMed:'Media', prioLow:'Baja',
|
|
1678
|
+
typeFeature:'Feature', typeBug:'Bug', typeConfig:'Config', typeRefactor:'Refactor', typeTest:'Test',
|
|
1679
|
+
cancel:'Cancelar', createTask:'Crear tarea',
|
|
1680
|
+
retryTitle:'Editar y reintentar tarea fallida',
|
|
1681
|
+
noErrorLog:'No se encontró log de error.',
|
|
1682
|
+
agentNoteLabel:'💬 Nota para el agente (pista para corregir el problema)',
|
|
1683
|
+
agentNotePlaceholder:'Ej: Usá tailwind v3 no v4. El error es por el módulo X...',
|
|
1684
|
+
retryBtn:'↩ Reintentar',
|
|
1685
|
+
terminal:'⌨️ Terminal',
|
|
1686
|
+
statusStopped:'detenido', statusInstalling:'instalando', statusStarting:'iniciando',
|
|
1687
|
+
statusRunning:'activo', statusError:'error',
|
|
1688
|
+
startExpo:'Iniciar Expo', stopExpo:'Detener',
|
|
1689
|
+
expoIdle:'Expo no iniciado. Hacé clic en "Iniciar Expo" para instalar dependencias y lanzar con tunnel.',
|
|
1690
|
+
scanWith:'ESCANEAR CON EXPO GO',
|
|
1691
|
+
retryCard:'↩ Reintentar',
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
let currentLang = localStorage.getItem('cb-lang') || 'en';
|
|
1696
|
+
|
|
1697
|
+
function t(key) {
|
|
1698
|
+
return STRINGS[currentLang][key] ?? STRINGS.en[key] ?? key;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function setLang(lang) {
|
|
1702
|
+
currentLang = lang;
|
|
1703
|
+
localStorage.setItem('cb-lang', lang);
|
|
1704
|
+
document.getElementById('lang-en').classList.toggle('active', lang === 'en');
|
|
1705
|
+
document.getElementById('lang-es').classList.toggle('active', lang === 'es');
|
|
1706
|
+
applyTranslations();
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function applyTranslations() {
|
|
1710
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
1711
|
+
el.innerHTML = t(el.getAttribute('data-i18n'));
|
|
1712
|
+
});
|
|
1713
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
1714
|
+
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
|
1715
|
+
});
|
|
1716
|
+
// Re-sync expo badge text based on current status class
|
|
1717
|
+
['expoBadge','expoPanelBadge'].forEach(id => {
|
|
1718
|
+
const el = document.getElementById(id);
|
|
1719
|
+
if (!el) return;
|
|
1720
|
+
const status = [...el.classList].find(c =>
|
|
1721
|
+
['stopped','installing','starting','running','error'].includes(c)
|
|
1722
|
+
) || 'stopped';
|
|
1723
|
+
el.textContent = t('status' + status.charAt(0).toUpperCase() + status.slice(1));
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1605
1727
|
// ── INIT ──────────────────────────────────────────────────────────────────────
|
|
1606
1728
|
loadBoard();
|
|
1607
1729
|
setInterval(loadBoard, 8000);
|
|
@@ -1609,6 +1731,13 @@ connectWS();
|
|
|
1609
1731
|
|
|
1610
1732
|
// Load expo status
|
|
1611
1733
|
fetch('/api/expo/status').then(r => r.json()).then(d => setExpoStatus(d.status, d.url));
|
|
1734
|
+
|
|
1735
|
+
// Apply saved language on load
|
|
1736
|
+
(function() {
|
|
1737
|
+
document.getElementById('lang-en').classList.toggle('active', currentLang === 'en');
|
|
1738
|
+
document.getElementById('lang-es').classList.toggle('active', currentLang === 'es');
|
|
1739
|
+
applyTranslations();
|
|
1740
|
+
})();
|
|
1612
1741
|
</script>
|
|
1613
1742
|
</body>
|
|
1614
1743
|
</html>
|
package/dashboard/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "url";
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import { spawn } from "child_process";
|
|
10
10
|
import { createRequire } from "module";
|
|
11
|
+
import { createConnection } from "net";
|
|
11
12
|
|
|
12
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const require = createRequire(import.meta.url);
|
|
@@ -126,14 +127,53 @@ termWss.on("connection", (ws) => {
|
|
|
126
127
|
|
|
127
128
|
// ── EXPO MANAGEMENT ───────────────────────────────────────────────────────────
|
|
128
129
|
|
|
130
|
+
// On server start, check if Expo is already running on the port
|
|
131
|
+
// (e.g. started by claudeboard run) and mark it as running
|
|
132
|
+
async function detectExistingExpo() {
|
|
133
|
+
const port = parseInt(process.env.EXPO_PORT || "8081");
|
|
134
|
+
const running = await isPortOpen(port);
|
|
135
|
+
if (running) {
|
|
136
|
+
expoStatus = "running";
|
|
137
|
+
expoUrl = `exp://localhost:${port}`;
|
|
138
|
+
broadcast("expo_log", { message: `✓ Detected existing Expo on port ${port} — ready to scan` });
|
|
139
|
+
broadcastExpoStatus();
|
|
140
|
+
console.log(` Expo already running on port ${port} — attached`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPortOpen(port) {
|
|
145
|
+
return new Promise(resolve => {
|
|
146
|
+
const sock = createConnection({ port, host: "127.0.0.1" });
|
|
147
|
+
sock.setTimeout(600);
|
|
148
|
+
sock.once("connect", () => { sock.destroy(); resolve(true); });
|
|
149
|
+
sock.once("error", () => resolve(false));
|
|
150
|
+
sock.once("timeout", () => resolve(false));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
129
154
|
// GET expo status
|
|
130
155
|
app.get("/api/expo/status", (req, res) => {
|
|
131
156
|
res.json({ status: expoStatus, qr: expoQR, url: expoUrl });
|
|
132
157
|
});
|
|
133
158
|
|
|
134
|
-
// POST expo/start — install
|
|
159
|
+
// POST expo/start — smart start: attach if already running, otherwise install + start
|
|
135
160
|
app.post("/api/expo/start", async (req, res) => {
|
|
136
|
-
|
|
161
|
+
const port = parseInt(process.env.EXPO_PORT || "8081");
|
|
162
|
+
|
|
163
|
+
// Already managed by this server
|
|
164
|
+
if (expoProcess) {
|
|
165
|
+
return res.json({ ok: true, message: "Already running", status: expoStatus, url: expoUrl });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if something is already listening on the port (e.g. started by claudeboard run)
|
|
169
|
+
const alreadyRunning = await isPortOpen(port);
|
|
170
|
+
if (alreadyRunning) {
|
|
171
|
+
expoStatus = "running";
|
|
172
|
+
expoUrl = `exp://localhost:${port}`;
|
|
173
|
+
broadcastExpoStatus();
|
|
174
|
+
broadcast("expo_log", { message: `✓ Attached to existing Expo on port ${port}` });
|
|
175
|
+
return res.json({ ok: true, message: "Attached to existing Expo", url: expoUrl });
|
|
176
|
+
}
|
|
137
177
|
|
|
138
178
|
res.json({ ok: true, message: "Starting Expo..." });
|
|
139
179
|
_startExpo(PROJECT_DIR);
|
|
@@ -153,69 +193,79 @@ app.post("/api/expo/stop", (req, res) => {
|
|
|
153
193
|
});
|
|
154
194
|
|
|
155
195
|
async function _startExpo(projectDir) {
|
|
156
|
-
// Step 1:
|
|
196
|
+
// Step 1: install deps with --legacy-peer-deps
|
|
197
|
+
// Expo projects almost always have peer dep conflicts — this is expected
|
|
157
198
|
expoStatus = "installing";
|
|
158
199
|
broadcastExpoStatus();
|
|
159
|
-
broadcast("expo_log", { message: "Installing dependencies..." });
|
|
200
|
+
broadcast("expo_log", { message: "Installing dependencies (--legacy-peer-deps)..." });
|
|
160
201
|
|
|
161
202
|
await new Promise((resolve) => {
|
|
162
|
-
const install = spawn("npm", ["install"
|
|
163
|
-
|
|
164
|
-
|
|
203
|
+
const install = spawn("npm", ["install", "--legacy-peer-deps"], {
|
|
204
|
+
cwd: projectDir,
|
|
205
|
+
stdio: "pipe",
|
|
206
|
+
env: { ...process.env },
|
|
207
|
+
});
|
|
208
|
+
install.stdout.on("data", (d) => {
|
|
209
|
+
const msg = d.toString().trim();
|
|
210
|
+
if (msg && !msg.startsWith("npm warn")) broadcast("expo_log", { message: msg });
|
|
211
|
+
});
|
|
212
|
+
install.stderr.on("data", (d) => {
|
|
213
|
+
const msg = d.toString().trim();
|
|
214
|
+
// Only show real errors, not peer dep warnings
|
|
215
|
+
if (msg && msg.includes("npm error") && !msg.includes("ERESOLVE")) {
|
|
216
|
+
broadcast("expo_log", { message: msg });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
165
219
|
install.on("close", resolve);
|
|
166
220
|
});
|
|
167
221
|
|
|
168
|
-
broadcast("expo_log", { message: "Dependencies
|
|
222
|
+
broadcast("expo_log", { message: "✓ Dependencies ready. Starting Expo tunnel..." });
|
|
169
223
|
|
|
170
|
-
// Step 2: expo start
|
|
224
|
+
// Step 2: expo start --tunnel, fully non-interactive
|
|
171
225
|
expoStatus = "starting";
|
|
172
226
|
broadcastExpoStatus();
|
|
173
227
|
|
|
174
|
-
const expo = spawn("npx", ["expo", "start", "--tunnel"], {
|
|
228
|
+
const expo = spawn("npx", ["expo", "start", "--tunnel", "--non-interactive"], {
|
|
175
229
|
cwd: projectDir,
|
|
176
|
-
env: { ...process.env, CI: "false", EXPO_NO_DOTENV: "0" },
|
|
177
230
|
stdio: "pipe",
|
|
231
|
+
env: {
|
|
232
|
+
...process.env,
|
|
233
|
+
CI: "1", // Prevents "use port X instead?" prompts
|
|
234
|
+
EXPO_NO_DOTENV: "0",
|
|
235
|
+
EXPO_NO_INTERACTIVE: "1", // Belt + suspenders non-interactive
|
|
236
|
+
TERM: "dumb", // No ANSI color codes in output
|
|
237
|
+
},
|
|
178
238
|
});
|
|
179
239
|
|
|
180
240
|
expoProcess = expo;
|
|
181
241
|
|
|
182
242
|
expo.stdout.on("data", (d) => {
|
|
183
243
|
const text = d.toString();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// Detect QR code URL (exp:// or https://expo.dev)
|
|
187
|
-
const expUrl = text.match(/exp:\/\/[^\s]+/);
|
|
188
|
-
if (expUrl) {
|
|
189
|
-
expoUrl = expUrl[0];
|
|
190
|
-
expoStatus = "running";
|
|
191
|
-
broadcastExpoStatus();
|
|
192
|
-
}
|
|
244
|
+
const clean = text.replace(/\x1b\[[0-9;]*m/g, "").trim(); // strip ANSI codes
|
|
245
|
+
if (clean) broadcast("expo_log", { message: clean });
|
|
193
246
|
|
|
194
|
-
// Detect
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
expoUrl = tunnel[0];
|
|
198
|
-
expoStatus = "running";
|
|
199
|
-
broadcastExpoStatus();
|
|
200
|
-
}
|
|
247
|
+
// Detect URLs
|
|
248
|
+
const expUrl = text.match(/exp:\/\/[^\s\]]+/);
|
|
249
|
+
if (expUrl) { expoUrl = expUrl[0]; expoStatus = "running"; broadcastExpoStatus(); }
|
|
201
250
|
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
251
|
+
const tunnel = text.match(/https:\/\/[a-z0-9-]+\.exp\.direct[^\s\]]*/);
|
|
252
|
+
if (tunnel) { expoUrl = tunnel[0]; expoStatus = "running"; broadcastExpoStatus(); }
|
|
253
|
+
|
|
254
|
+
if (text.includes("scan") || text.includes("QR")) {
|
|
255
|
+
broadcast("expo_log", { message: "📱 QR ready — open Expo Go and scan" });
|
|
205
256
|
}
|
|
206
257
|
});
|
|
207
258
|
|
|
208
259
|
expo.stderr.on("data", (d) => {
|
|
209
|
-
const text = d.toString().trim();
|
|
260
|
+
const text = d.toString().replace(/\x1b\[[0-9;]*m/g, "").trim();
|
|
210
261
|
if (text) broadcast("expo_log", { message: text });
|
|
211
262
|
});
|
|
212
263
|
|
|
213
264
|
expo.on("close", (code) => {
|
|
214
265
|
expoProcess = null;
|
|
215
|
-
|
|
216
|
-
expoQR = null;
|
|
266
|
+
if (code !== 0 && expoStatus !== "running") expoStatus = "error";
|
|
217
267
|
broadcastExpoStatus();
|
|
218
|
-
broadcast("expo_log", { message: `Expo
|
|
268
|
+
broadcast("expo_log", { message: `Expo exited (code ${code})` });
|
|
219
269
|
});
|
|
220
270
|
}
|
|
221
271
|
|
|
@@ -312,4 +362,8 @@ async function addLog(taskId, message, type = "info") {
|
|
|
312
362
|
await supabase.from("cb_logs").insert({ project: PROJECT, task_id: taskId, message, type });
|
|
313
363
|
}
|
|
314
364
|
|
|
315
|
-
server.listen(PORT, () =>
|
|
365
|
+
server.listen(PORT, () => {
|
|
366
|
+
console.log(`READY on port ${PORT}`);
|
|
367
|
+
// Check if Expo is already running (started by claudeboard run)
|
|
368
|
+
setTimeout(detectExistingExpo, 1000);
|
|
369
|
+
});
|