cmdzero 0.8.3 → 0.8.5

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.
@@ -1326,14 +1326,36 @@
1326
1326
  for (const rec of saved) addTweak(rec, true); // chronological replay → newest ends at the bottom
1327
1327
  })();
1328
1328
 
1329
- try {
1330
- const es = new EventSource(`${ORIGIN}/api/events`);
1331
- es.onmessage = (m) => {
1332
- const e = JSON.parse(m.data);
1333
- if (e.type === 'tweak') addTweak(e);
1334
- if (e.type === 'totals') showTotals(e.totals);
1335
- };
1336
- } catch { /* daemon offline */ }
1329
+ // A restarted daemon leaves the EventSource holding a socket to a process that
1330
+ // is gone: the browser keeps it in readyState OPEN, fires no error and never
1331
+ // reconnects, so alerts stop arriving while the edits behind them still land
1332
+ // on disk. Nothing surfaces that — the tray just quietly stops moving. The
1333
+ // daemon pings every 15s, so silence well past that means the stream is a
1334
+ // zombie; drop it and dial again.
1335
+ const SSE_SILENCE_MS = 45000;
1336
+ let es = null;
1337
+ let lastSeen = Date.now();
1338
+ function connectEvents() {
1339
+ try {
1340
+ if (es) es.close();
1341
+ es = new EventSource(`${ORIGIN}/api/events`);
1342
+ es.onopen = () => { lastSeen = Date.now(); };
1343
+ es.onmessage = (m) => {
1344
+ lastSeen = Date.now();
1345
+ const e = JSON.parse(m.data);
1346
+ if (e.type === 'tweak') addTweak(e);
1347
+ if (e.type === 'totals') showTotals(e.totals);
1348
+ // 'ping' needs no handling — arriving at all is the whole point.
1349
+ };
1350
+ } catch { /* daemon offline */ }
1351
+ }
1352
+ connectEvents();
1353
+ setInterval(() => {
1354
+ if (Date.now() - lastSeen > SSE_SILENCE_MS) {
1355
+ lastSeen = Date.now(); // reset first, so a daemon that is still down doesn't reconnect every tick
1356
+ connectEvents();
1357
+ }
1358
+ }, 5000);
1337
1359
  fetch(`${ORIGIN}/api/health`)
1338
1360
  .then((r) => r.json())
1339
1361
  .then((h) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmdzero",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/resolver.js CHANGED
@@ -18,25 +18,32 @@ function walk(node, cb) {
18
18
  }
19
19
  }
20
20
 
21
- /** loc format: "<relative file>:<line 1-based>:<col 0-based>" (matches the babel stamp) */
21
+ /**
22
+ * loc format: "<file>:<line 1-based>:<col 0-based>" (matches the babel stamp).
23
+ *
24
+ * The file arrives in one of three shapes, depending on who stamped it: the
25
+ * babel plugin emits a root-relative path, and the @cmdzero/react dev runtime
26
+ * passes on whatever the bundler handed jsxDEV — absolute under webpack, but
27
+ * "[project]/components/Foo.tsx" under Turbopack. Normalize that prefix away
28
+ * here, at the boundary every caller shares: /api/nl parses locs itself and
29
+ * resolves them straight against the root, so normalizing further in (say, in
30
+ * loadTarget) leaves that lane reaching for <root>/[project]/... and dying on
31
+ * ENOENT while the copy and style lanes look fine.
32
+ */
22
33
  export function parseLoc(loc) {
23
34
  const m = /^(.*):(\d+):(\d+)$/.exec(loc);
24
35
  if (!m) throw new Error(`bad loc: ${loc}`);
25
- return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
36
+ return { file: m[1].replace(/^\[project\]\//, ''), line: Number(m[2]), col: Number(m[3]) };
26
37
  }
27
38
 
28
39
  export function loadTarget(root, loc) {
29
40
  const parsed = parseLoc(loc);
30
41
  const { line, col } = parsed;
31
- // Stamps come from three sources: the babel plugin (root-relative path,
32
- // 0-based column) and the @cmdzero/react dev runtime (1-based column), which
33
- // reports whatever the bundler handed jsxDEV an absolute path under
34
- // webpack, but "[project]/..." under Turbopack. Normalize the path and match
35
- // either column convention.
36
- const raw = parsed.file.replace(/^\[project\]\//, '');
37
- // Resolve before the escape check: the guard has to see a normalized path,
38
- // or a "foo/../../.." stamp walks straight past a leading-".." test.
39
- const file = path.relative(root, path.resolve(root, raw));
42
+ // parseLoc has already normalized the path's shape; columns still come in two
43
+ // conventions (babel 0-based, the dev runtime 1-based) and are matched below.
44
+ // Resolve before the escape check: the guard has to see a normalized path, or
45
+ // a "foo/../../.." stamp walks straight past a leading-".." test.
46
+ const file = path.relative(root, path.resolve(root, parsed.file));
40
47
  if (file.startsWith('..') || path.isAbsolute(file)) {
41
48
  throw new Error('file outside root');
42
49
  }
package/src/server.js CHANGED
@@ -28,11 +28,18 @@ function detectTailwind(root) {
28
28
  }
29
29
  }
30
30
 
31
- export function startServer({ root, port = 4100 }) {
31
+ export function startServer({ root, port = 4100, heartbeatMs = 15000 }) {
32
32
  const undoStack = new Map(); // id -> { abs, before }
33
33
  const running = new Map(); // id -> child process (for cancellation)
34
34
  const sseClients = new Set();
35
- let nextId = 1;
35
+ // Seeded from the clock, not 1. The overlay persists its alert history in
36
+ // localStorage and keys each row by tweak id, and that history outlives the
37
+ // daemon — so a counter starting over at 1 hands a fresh tweak the id of a row
38
+ // hydrated from an earlier session. addTweak() updates a known id in place, so
39
+ // the newest alert would silently overwrite that old row wherever it sits
40
+ // instead of appending to the bottom of the tray. Still ascending within a
41
+ // session; just never reissued across a restart.
42
+ let nextId = Date.now();
36
43
  const tailwind = detectTailwind(root);
37
44
  const telemetry = initTelemetry({ version: VERSION, tailwind });
38
45
 
@@ -98,7 +105,20 @@ export function startServer({ root, port = 4100 }) {
98
105
  });
99
106
  res.write('\n');
100
107
  sseClients.add(res);
101
- req.on('close', () => sseClients.delete(res));
108
+ // Idle streams have to say something. A browser can't tell a quiet
109
+ // daemon from a dead one: the EventSource stays in readyState OPEN,
110
+ // never fires error and never reconnects, so every alert after a
111
+ // restart is dropped while the writes still land on disk. A ping the
112
+ // client can actually see (a comment would not reach onmessage) lets it
113
+ // notice the silence and reconnect.
114
+ const beat = setInterval(() => {
115
+ res.write(`data: ${JSON.stringify({ type: 'ping' })}\n\n`);
116
+ }, heartbeatMs);
117
+ beat.unref?.(); // never hold the process open for a heartbeat
118
+ req.on('close', () => {
119
+ clearInterval(beat);
120
+ sseClients.delete(res);
121
+ });
102
122
  return;
103
123
  }
104
124