claudeboard 2.12.0 → 2.14.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.
@@ -70,6 +70,7 @@ RULES:
70
70
  - After writing files run: npx tsc --noEmit — fix any errors you find
71
71
  - If you hit an error, read it carefully and fix it — iterate until it works
72
72
  - Do NOT ask questions or ask for confirmation. Make your best judgment.
73
+ - Do NOT run npx expo run:ios or npx expo run:android — the orchestrator handles native builds
73
74
  - When fully done, print EXACTLY this line: TASK_COMPLETE: <one sentence summary>
74
75
  `;
75
76
 
@@ -4,11 +4,15 @@ import { createTask, createEpic, addLog } from "./board-client.js";
4
4
  import chalk from "chalk";
5
5
  import fs from "fs";
6
6
  import path from "path";
7
+ import crypto from "crypto";
7
8
  import { createRequire } from "module";
8
9
  import { createConnection } from "net";
10
+ import { spawn as _spawn, execSync } from "child_process";
9
11
 
10
12
  const require = createRequire(import.meta.url);
11
13
  const MAX_FIX_ATTEMPTS = 5;
14
+ const BUILD_HASH_FILE = ".claudeboard-build-hash.txt";
15
+ const BUILD_TIMEOUT_MS = 10 * 60 * 1000; // 10 min max for expo run:ios
12
16
 
13
17
  /**
14
18
  * Expo Health Check Agent — runs BEFORE the development loop.
@@ -16,9 +20,10 @@ const MAX_FIX_ATTEMPTS = 5;
16
20
  * Strategy:
17
21
  * 1. Try npm install (--legacy-peer-deps, then --force if needed)
18
22
  * 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 attemptsinject a BLOCKER task into the board
23
+ * 3. If iOS mode: ensure a dev build exists in the simulator (builds if needed)
24
+ * 4. Start Expo, wait for Metro to be truly ready (not just port open)
25
+ * 5. If Metro crashes with module/dep errorsask Claude apply retry
26
+ * 6. If still broken after MAX attempts → inject a BLOCKER task into the board
22
27
  * so the developer agent fixes it before any other task runs
23
28
  *
24
29
  * Returns { ready: boolean, process: ChildProcess|null }
@@ -43,7 +48,22 @@ export async function runExpoHealthCheck(projectPath, port = 8081) {
43
48
  return { ready: false, process: null };
44
49
  }
45
50
 
46
- // ── 2. Start Expo + verify Metro is error-free ─────────────────────────────
51
+ // ── 2. Dev build (iOS mode only) ───────────────────────────────────────────
52
+ const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
53
+ if (useIOS) {
54
+ const buildOk = await ensureDevBuild(projectPath);
55
+ if (!buildOk) {
56
+ await injectFixTask(projectPath,
57
+ "FIX: Dev build failed — fix native compilation errors",
58
+ "npx expo run:ios failed. Fix any native dependency or Podfile issues so the app compiles.\n" +
59
+ "Run: npx expo run:ios --simulator and fix all errors."
60
+ );
61
+ console.log(chalk.yellow(" ✗ Dev build failed — injected fix task into board\n"));
62
+ return { ready: false, process: null };
63
+ }
64
+ }
65
+
66
+ // ── 3. Start Expo + verify Metro is error-free ─────────────────────────────
47
67
  for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
48
68
  console.log(chalk.dim(` Starting Expo (attempt ${attempt}/${MAX_FIX_ATTEMPTS})...`));
49
69
 
@@ -83,6 +103,217 @@ export async function runExpoHealthCheck(projectPath, port = 8081) {
83
103
  return { ready: false, process: null };
84
104
  }
85
105
 
106
+ // ── Dev Build management ───────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Compute a short hash of package.json to detect dependency changes.
110
+ */
111
+ export function packageJsonHash(projectPath) {
112
+ try {
113
+ const content = fs.readFileSync(path.join(projectPath, "package.json"), "utf8");
114
+ return crypto.createHash("md5").update(content).digest("hex").slice(0, 12);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Returns true if a rebuild is needed:
122
+ * - No saved hash (never built), OR
123
+ * - package.json changed since last build
124
+ */
125
+ export function needsRebuild(projectPath) {
126
+ const hashFile = path.join(projectPath, BUILD_HASH_FILE);
127
+ if (!fs.existsSync(hashFile)) return true;
128
+ try {
129
+ const saved = fs.readFileSync(hashFile, "utf8").trim();
130
+ return saved !== packageJsonHash(projectPath);
131
+ } catch {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Persist the current package.json hash after a successful build.
138
+ */
139
+ function saveBuildHash(projectPath) {
140
+ try {
141
+ const hash = packageJsonHash(projectPath);
142
+ if (hash) fs.writeFileSync(path.join(projectPath, BUILD_HASH_FILE), hash, "utf8");
143
+ } catch {}
144
+ }
145
+
146
+ /**
147
+ * Check if the app's dev build is already installed in the booted simulator.
148
+ */
149
+ function isDevBuildInstalled(projectPath) {
150
+ try {
151
+ const bundleId = getBundleId(projectPath);
152
+ if (!bundleId) return false;
153
+ const output = execSync(`xcrun simctl get_app_container booted "${bundleId}" 2>&1`, { encoding: "utf8" });
154
+ return output.trim().length > 0 && !output.includes("No such file");
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Read the iOS bundle identifier from app.json / app.config.js.
162
+ * Falls back to deriving it from the project slug.
163
+ */
164
+ function getBundleId(projectPath) {
165
+ try {
166
+ const appJsonPath = path.join(projectPath, "app.json");
167
+ if (fs.existsSync(appJsonPath)) {
168
+ const cfg = JSON.parse(fs.readFileSync(appJsonPath, "utf8"));
169
+ const bundleId = cfg?.expo?.ios?.bundleIdentifier;
170
+ if (bundleId) return bundleId;
171
+ // Derive from slug if no explicit bundleIdentifier
172
+ const slug = cfg?.expo?.slug;
173
+ if (slug) return `com.anonymous.${slug.replace(/[^a-zA-Z0-9]/g, "")}`;
174
+ }
175
+ } catch {}
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Boot the first available iOS simulator if none is booted.
181
+ */
182
+ function ensureSimulatorBooted() {
183
+ try {
184
+ const booted = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
185
+ if (booted.includes("Booted")) return true;
186
+
187
+ // Find a suitable device to boot
188
+ const devices = execSync("xcrun simctl list devices available 2>&1", { encoding: "utf8" });
189
+ const match = devices.match(/iPhone \d[^(]*\(([A-F0-9-]{36})\)/i);
190
+ if (!match) return false;
191
+
192
+ console.log(chalk.dim(` Booting simulator ${match[1]}...`));
193
+ execSync(`xcrun simctl boot "${match[1]}" 2>&1`, { encoding: "utf8" });
194
+ // Give the simulator a moment to fully boot
195
+ execSync("sleep 5");
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Build and install the dev build in the simulator using `expo run:ios`.
204
+ * Only rebuilds when package.json changed or the app is not installed.
205
+ * Returns true on success, false on failure.
206
+ */
207
+ export async function ensureDevBuild(projectPath) {
208
+ const installed = isDevBuildInstalled(projectPath);
209
+ const rebuild = needsRebuild(projectPath);
210
+
211
+ if (installed && !rebuild) {
212
+ console.log(chalk.dim(" ✓ Dev build up to date — skipping rebuild"));
213
+ return true;
214
+ }
215
+
216
+ const reason = !installed ? "app not installed" : "dependencies changed";
217
+ console.log(chalk.cyan(` Building dev build (${reason})...`));
218
+ console.log(chalk.dim(" This may take several minutes on first run."));
219
+
220
+ // Make sure a simulator is running
221
+ const simReady = ensureSimulatorBooted();
222
+ if (!simReady) {
223
+ console.log(chalk.yellow(" ✗ No iOS simulator available"));
224
+ return false;
225
+ }
226
+
227
+ return new Promise((resolve) => {
228
+ let output = "";
229
+ let resolved = false;
230
+
231
+ const done = (ok) => {
232
+ if (resolved) return;
233
+ resolved = true;
234
+ if (ok) {
235
+ saveBuildHash(projectPath);
236
+ console.log(chalk.green(" ✓ Dev build installed in simulator"));
237
+ }
238
+ resolve(ok);
239
+ };
240
+
241
+ const timeout = setTimeout(() => {
242
+ console.log(chalk.yellow(" ✗ Dev build timed out after 10 minutes"));
243
+ done(false);
244
+ }, BUILD_TIMEOUT_MS);
245
+
246
+ try {
247
+ const proc = _spawn("npx", ["expo", "run:ios", "--simulator"], {
248
+ cwd: projectPath,
249
+ env: { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1" },
250
+ stdio: "pipe",
251
+ });
252
+
253
+ const onData = (d) => {
254
+ const text = d.toString();
255
+ output += text;
256
+ // Log progress lines that are meaningful
257
+ const lines = text.split("\n").filter(l => l.trim() && !l.includes("\u001b["));
258
+ for (const line of lines) {
259
+ if (
260
+ line.includes("Installing") ||
261
+ line.includes("Building") ||
262
+ line.includes("Compiling") ||
263
+ line.includes("Linking") ||
264
+ line.includes("error:") ||
265
+ line.includes("warning:")
266
+ ) {
267
+ console.log(chalk.dim(` ${line.trim().slice(0, 100)}`));
268
+ }
269
+ }
270
+
271
+ if (
272
+ text.includes("Installed on") ||
273
+ text.includes("Opening on") ||
274
+ text.includes("Successfully built") ||
275
+ text.includes("BUILD SUCCEEDED")
276
+ ) {
277
+ clearTimeout(timeout);
278
+ done(true);
279
+ }
280
+
281
+ if (
282
+ text.includes("BUILD FAILED") ||
283
+ text.includes("error: ") ||
284
+ text.includes("Command failed")
285
+ ) {
286
+ // Wait a bit to collect full error output
287
+ setTimeout(() => {
288
+ clearTimeout(timeout);
289
+ saveLastError(projectPath, output.slice(-3000));
290
+ done(false);
291
+ }, 2000);
292
+ }
293
+ };
294
+
295
+ proc.stdout.on("data", onData);
296
+ proc.stderr.on("data", onData);
297
+
298
+ proc.on("close", (code) => {
299
+ clearTimeout(timeout);
300
+ if (!resolved) {
301
+ if (code === 0) {
302
+ done(true);
303
+ } else {
304
+ saveLastError(projectPath, output.slice(-3000));
305
+ done(false);
306
+ }
307
+ }
308
+ });
309
+
310
+ } catch (e) {
311
+ clearTimeout(timeout);
312
+ done(false);
313
+ }
314
+ });
315
+ }
316
+
86
317
  // ── npm install with progressive fallback ─────────────────────────────────
87
318
  async function installDeps(projectPath) {
88
319
  const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
@@ -149,14 +380,13 @@ async function tryStartExpo(projectPath, port) {
149
380
  done(false, `Expo did not become ready within 60s.\n${output.slice(-800)}`), 60000);
150
381
 
151
382
  try {
152
- const { spawn } = require("child_process");
153
- // Use iOS simulator if available, fallback to web
154
- const useIOS = process.env.CLAUDEBOARD_IOS === "1" || await isSimulatorAvailable();
383
+ // iOS mode: use dev build (--go is intentionally omitted so the installed dev build opens)
384
+ const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
155
385
  const expoArgs = useIOS
156
386
  ? ["expo", "start", "--ios", "--port", String(port)]
157
387
  : ["expo", "start", "--web", "--port", String(port)];
158
388
 
159
- proc = spawn("npx", expoArgs, {
389
+ proc = _spawn("npx", expoArgs, {
160
390
  cwd: projectPath,
161
391
  env: (() => { const e = { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1", EXPO_NO_DOTENV: "0" }; delete e.ANTHROPIC_API_KEY; return e; })(),
162
392
  stdio: "pipe",
@@ -320,13 +550,10 @@ function getFatalError(output) {
320
550
  }
321
551
 
322
552
  // ── iOS Simulator detection ───────────────────────────────────────────────────
323
- async function isSimulatorAvailable() {
553
+ function isSimulatorAvailableSync() {
324
554
  try {
325
- const { execSync } = await import("child_process");
326
555
  const output = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
327
- // Check if any device is already booted
328
556
  if (output.includes("Booted")) return true;
329
- // Check if any iOS runtime is available
330
557
  const runtimes = execSync("xcrun simctl list runtimes 2>&1", { encoding: "utf8" });
331
558
  return runtimes.includes("iOS") && !runtimes.includes("unavailable");
332
559
  } catch {
@@ -337,7 +564,6 @@ async function isSimulatorAvailable() {
337
564
  // Take a screenshot of the iOS simulator
338
565
  export async function screenshotSimulator(outputPath) {
339
566
  try {
340
- const { execSync } = await import("child_process");
341
567
  execSync(`xcrun simctl io booted screenshot "${outputPath}"`, { stdio: "pipe" });
342
568
  const data = fs.readFileSync(outputPath);
343
569
  return { success: true, base64: data.toString("base64"), path: outputPath };
@@ -349,7 +575,6 @@ export async function screenshotSimulator(outputPath) {
349
575
  // Tap on the iOS simulator at given coordinates
350
576
  export async function tapSimulator(x, y) {
351
577
  try {
352
- const { execSync } = await import("child_process");
353
578
  execSync(`xcrun simctl io booted tap ${x} ${y}`, { stdio: "pipe" });
354
579
  return true;
355
580
  } catch { return false; }
@@ -5,7 +5,7 @@ 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";
8
+ import { runExpoHealthCheck, ensureDevBuild, packageJsonHash } from "./expo-health.js";
9
9
  import { createConnection } from "net";
10
10
 
11
11
  function isPortOpen(port) {
@@ -112,6 +112,9 @@ export async function runOrchestrator(config) {
112
112
  let consecutiveFailures = 0;
113
113
  const MAX_CONSECUTIVE_FAILURES = 3;
114
114
 
115
+ // Track package.json hash to detect native dependency changes that need a rebuild
116
+ let lastPkgHash = packageJsonHash(projectPath);
117
+
115
118
  while (true) {
116
119
  const task = await getNextTask();
117
120
 
@@ -153,6 +156,29 @@ export async function runOrchestrator(config) {
153
156
  expoProcess = recheck.process;
154
157
  }
155
158
 
159
+ // ── Detect native dependency changes → rebuild dev build if needed ────────
160
+ if (useIOS) {
161
+ const currentHash = packageJsonHash(projectPath);
162
+ if (currentHash && currentHash !== lastPkgHash) {
163
+ lastPkgHash = currentHash;
164
+ console.log(chalk.cyan(" 📦 package.json changed — checking if dev build rebuild is needed..."));
165
+ if (expoProcess) {
166
+ try { expoProcess.kill(); } catch {}
167
+ expoProcess = null;
168
+ expoReady = false;
169
+ }
170
+ const buildOk = await ensureDevBuild(projectPath);
171
+ if (buildOk) {
172
+ // Restart Metro after rebuild
173
+ const recheck = await runExpoHealthCheck(projectPath, expoPort);
174
+ expoReady = recheck.ready;
175
+ expoProcess = recheck.process;
176
+ } else {
177
+ console.log(chalk.yellow(" ✗ Rebuild failed — continuing without live preview"));
178
+ }
179
+ }
180
+ }
181
+
156
182
  // ── Re-check Expo health if it's supposed to be running but isn't ────────
157
183
  if (expoReady) {
158
184
  const portOpen = await isPortOpen(expoPort);
@@ -1201,13 +1201,23 @@ function connectWS() {
1201
1201
  ws.onclose = () => { setWS(false); setTimeout(connectWS, 2000); };
1202
1202
  ws.onmessage = (e) => {
1203
1203
  const { event, data } = JSON.parse(e.data);
1204
- if (['task_update','task_added','task_started','task_complete','task_failed','task_deleted','task_reordered'].includes(event)) {
1204
+
1205
+ // Task state changes that require a board reload (status flipped, new task, deleted)
1206
+ if (['task_added','task_started','task_complete','task_failed','task_deleted'].includes(event)) {
1205
1207
  loadBoard();
1206
1208
  }
1209
+ // Reorder: just re-render from current board data — no network call needed
1210
+ if (event === 'task_reordered') {
1211
+ renderKanban();
1212
+ updateStats();
1213
+ }
1214
+ // Single task field update: patch in-memory and re-render (no full reload)
1215
+ if (event === 'task_update') {
1216
+ patchTaskInMemory(data);
1217
+ }
1207
1218
  if (event === 'log') {
1208
1219
  board.logs.unshift(data);
1209
1220
  if (activeTab === 'activity') renderLogs();
1210
- // If on detail tab, don't touch activityPane — it'll refresh next time they switch
1211
1221
  }
1212
1222
  if (event === 'expo_status') {
1213
1223
  setExpoStatus(data.status, data.url);
@@ -1221,6 +1231,12 @@ function connectWS() {
1221
1231
  function setWS(on) {
1222
1232
  document.getElementById('wsDot').className = 'ws-dot' + (on ? ' on' : '');
1223
1233
  document.getElementById('wsLabel').textContent = on ? t('live') : t('reconnecting');
1234
+ // Poll only when disconnected
1235
+ if (on) {
1236
+ if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
1237
+ } else {
1238
+ if (!pollInterval) pollInterval = setInterval(loadBoard, 10000);
1239
+ }
1224
1240
  }
1225
1241
 
1226
1242
  // ── DATA ─────────────────────────────────────────────────────────────────────
@@ -1328,9 +1344,37 @@ function cardHTML(task, position = null) {
1328
1344
  </div>`;
1329
1345
  }
1330
1346
 
1331
- // ── DRAG & DROP ───────────────────────────────────────────────────────────────
1347
+ // ── IN-MEMORY PATCH (avoid full reload for minor updates) ─────────────────────
1348
+ function patchTaskInMemory(patch) {
1349
+ if (!board.epics) return;
1350
+ for (const epic of board.epics) {
1351
+ const idx = (epic.cb_tasks || []).findIndex(t => t.id === patch.id);
1352
+ if (idx !== -1) {
1353
+ epic.cb_tasks[idx] = { ...epic.cb_tasks[idx], ...patch };
1354
+ renderKanban();
1355
+ updateStats();
1356
+ return;
1357
+ }
1358
+ }
1359
+ // Task not found in memory yet (new orphan etc), do full reload
1360
+ loadBoard();
1361
+ }
1362
+
1363
+ // ── DRAG STATE ────────────────────────────────────────────────────────────────
1364
+ // We track order in JS so we don't depend on DOM state during drag
1365
+ let dragColumnOrder = {}; // { status: [id, id, ...] } — snapshot at dragstart
1332
1366
  function onDragStart(e, id) {
1333
1367
  draggedId = id;
1368
+
1369
+ // Snapshot current order of all columns from memory (reliable — not DOM-dependent)
1370
+ dragColumnOrder = {};
1371
+ for (const status of ['todo', 'in_progress', 'done', 'error']) {
1372
+ dragColumnOrder[status] = allTasks()
1373
+ .filter(t => t.status === status)
1374
+ .sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99))
1375
+ .map(t => t.id);
1376
+ }
1377
+
1334
1378
  setTimeout(() => {
1335
1379
  const el = document.querySelector(`.card[data-id="${id}"]`);
1336
1380
  if (el) el.classList.add('dragging');
@@ -1391,27 +1435,34 @@ async function onDrop(e, status) {
1391
1435
  const insertBeforeId = col.dataset.insertBefore || '';
1392
1436
  delete col.dataset.insertBefore;
1393
1437
 
1394
- // Build the new ordered list of ids for this column
1395
- // Start from the DOM what's visually shown — plus insert dragged card
1396
- const otherIds = [...col.querySelectorAll('.card')]
1397
- .map(c => c.dataset.id)
1398
- .filter(id => id && id !== draggedId);
1438
+ // Use the JS snapshot (taken at dragstart) not DOM which has .dragging gaps
1439
+ const sourceStatus = allTasks().find(t => t.id === draggedId)?.status || status;
1440
+ const columnIds = (dragColumnOrder[status] || []).filter(id => id !== draggedId);
1399
1441
 
1400
1442
  let newOrderedIds;
1401
1443
  if (!insertBeforeId) {
1402
- newOrderedIds = [...otherIds, draggedId]; // dropped at end
1444
+ newOrderedIds = [...columnIds, draggedId];
1403
1445
  } else {
1404
- const insertIdx = otherIds.indexOf(insertBeforeId);
1446
+ const insertIdx = columnIds.indexOf(insertBeforeId);
1405
1447
  if (insertIdx === -1) {
1406
- newOrderedIds = [draggedId, ...otherIds];
1448
+ newOrderedIds = [draggedId, ...columnIds];
1407
1449
  } else {
1408
- newOrderedIds = [...otherIds.slice(0, insertIdx), draggedId, ...otherIds.slice(insertIdx)];
1450
+ newOrderedIds = [...columnIds.slice(0, insertIdx), draggedId, ...columnIds.slice(insertIdx)];
1409
1451
  }
1410
1452
  }
1411
1453
 
1412
- // Update status first if moving to different column
1413
- const draggedTask = allTasks().find(t => t.id === draggedId);
1414
- if (draggedTask?.status !== status) {
1454
+ // Optimistic update in memory so re-render is instant
1455
+ const task = allTasks().find(t => t.id === draggedId);
1456
+ if (task) task.status = status;
1457
+ newOrderedIds.forEach((id, i) => {
1458
+ const t = allTasks().find(t => t.id === id);
1459
+ if (t) t.priority_order = i + 1;
1460
+ });
1461
+ renderKanban();
1462
+ updateStats();
1463
+
1464
+ // Update status if column changed
1465
+ if (sourceStatus !== status) {
1415
1466
  await fetch(`/api/tasks/${draggedId}`, {
1416
1467
  method: 'PATCH',
1417
1468
  headers: { 'Content-Type': 'application/json' },
@@ -1419,7 +1470,7 @@ async function onDrop(e, status) {
1419
1470
  });
1420
1471
  }
1421
1472
 
1422
- // Persist the new order in one call
1473
+ // Persist new order
1423
1474
  await fetch('/api/tasks/reorder', {
1424
1475
  method: 'POST',
1425
1476
  headers: { 'Content-Type': 'application/json' },
@@ -1427,7 +1478,6 @@ async function onDrop(e, status) {
1427
1478
  });
1428
1479
 
1429
1480
  draggedId = null;
1430
- loadBoard();
1431
1481
  }
1432
1482
 
1433
1483
  // ── CARD SELECT ───────────────────────────────────────────────────────────────
@@ -1901,8 +1951,9 @@ function applyTranslations() {
1901
1951
 
1902
1952
  // ── INIT ──────────────────────────────────────────────────────────────────────
1903
1953
  loadBoard();
1904
- setInterval(loadBoard, 8000);
1905
1954
  connectWS();
1955
+ // Only poll when WS is disconnected (fallback)
1956
+ let pollInterval = null;
1906
1957
 
1907
1958
  // Load expo status
1908
1959
  fetch('/api/expo/status').then(r => r.json()).then(d => setExpoStatus(d.status, d.url));
@@ -59,7 +59,11 @@ function broadcastExpoStatus() {
59
59
  // ── SUPABASE REALTIME ─────────────────────────────────────────────────────────
60
60
  supabase
61
61
  .channel("cb_changes")
62
- .on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => broadcast("task_update", p))
62
+ .on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => {
63
+ if (p.eventType === "UPDATE" && p.new) broadcast("task_update", p.new);
64
+ else if (p.eventType === "INSERT" && p.new) broadcast("task_added", p.new);
65
+ else if (p.eventType === "DELETE") broadcast("task_deleted", { id: p.old?.id });
66
+ })
63
67
  .on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (p) => broadcast("log", p.new))
64
68
  .subscribe();
65
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {