@logicseek/hello-cc 1.1.0-rc.7 → 1.1.0-rc.8

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/CHANGELOG.md CHANGED
@@ -16,6 +16,24 @@ the release description from the current changelog section. Use
16
16
  `npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
17
17
  backfills.
18
18
 
19
+ ## 1.1.0-rc.8
20
+
21
+ ### Summary
22
+
23
+ Background tmux display refreshes no longer block the shared HTTP runtime while
24
+ waiting for terminal snapshots. This preserves responsive Agent coordination
25
+ when a tmux display command is slow, and continues the existing preview channel.
26
+
27
+ ### Highlights
28
+
29
+ - Bound asynchronous background snapshots, keep one request in flight per
30
+ session, and preserve the previous display when capture fails or times out.
31
+ - Discard stale results after a session stops, changes panes or is rebound;
32
+ terminal display refresh does not replace process ownership checks.
33
+ - Retain the rc.7 original-App cooperation path and add delayed-tmux regression
34
+ coverage. A reproduced synchronous display stall motivated this repair; the
35
+ exact server-side cause of the earlier macOS CI timeout remains unconfirmed.
36
+
19
37
  ## 1.1.0-rc.7
20
38
 
21
39
  ### Summary
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
- import { spawnSync } from 'node:child_process';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { performance } from 'node:perf_hooks';
5
6
  import { CliError } from '../shared/errors.mjs';
6
7
  import { shellQuoteArg } from '../format.mjs';
7
8
  import {
@@ -284,20 +285,76 @@ export function tmuxCapturePane(pane) {
284
285
  return out.endsWith('\n') ? out.slice(0, -1) : out;
285
286
  }
286
287
 
288
+ // Display-only reads must not block the shared HTTP event loop. Both reads
289
+ // share one deadline; timeout/abort joins the child before a new read starts.
290
+ function readTmuxDisplay(args, { deadline, signal }) {
291
+ return new Promise((resolve, reject) => {
292
+ const remaining = deadline - performance.now();
293
+ if (signal?.aborted || remaining <= 0) {
294
+ reject(new CliError('TMUX_ERROR', 'tmux display read cancelled or timed out'));
295
+ return;
296
+ }
297
+ const child = spawn('tmux', args, { stdio: ['ignore', 'pipe', 'ignore'] });
298
+ const chunks = [];
299
+ let bytes = 0, failure = null;
300
+ const stop = message => {
301
+ failure ||= new CliError('TMUX_ERROR', message);
302
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
303
+ // A PATH wrapper can leave a descendant holding stdout after it exits.
304
+ // Release our read endpoint so that descendant cannot hold close/flight.
305
+ child.stdout.destroy();
306
+ };
307
+ const abort = () => stop('tmux display read cancelled');
308
+ const timer = setTimeout(() => stop('tmux display read timed out'), Math.ceil(remaining));
309
+ signal?.addEventListener('abort', abort, { once: true });
310
+ if (signal?.aborted) abort();
311
+ child.stdout.on('data', chunk => {
312
+ bytes += chunk.length;
313
+ if (bytes > 1024 * 1024) stop('tmux display output is too large');
314
+ else if (!failure) chunks.push(chunk);
315
+ });
316
+ child.once('error', () => { failure ||= new CliError('TMUX_ERROR', 'tmux display command failed'); });
317
+ child.once('close', code => {
318
+ clearTimeout(timer);
319
+ signal?.removeEventListener('abort', abort);
320
+ if (failure) reject(failure);
321
+ else if (signal?.aborted || performance.now() >= deadline) reject(new CliError('TMUX_ERROR', 'tmux display read cancelled or timed out'));
322
+ else if (code !== 0) reject(new CliError('TMUX_ERROR', 'tmux display command failed'));
323
+ else resolve(Buffer.concat(chunks).toString('utf8'));
324
+ });
325
+ });
326
+ }
327
+
328
+ export async function tmuxDisplaySnapshot(pane, { timeoutMs = 5000, signal } = {}) {
329
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000) {
330
+ throw new CliError('BAD_ARGS', 'tmux display deadline must be 1..5000ms');
331
+ }
332
+ const deadline = performance.now() + timeoutMs;
333
+ const options = { deadline, signal };
334
+ const output = await readTmuxDisplay(['capture-pane', '-p', '-e', '-S', '-2000', '-t', pane], options);
335
+ const cursor = await readTmuxDisplay(['display-message', '-p', '-t', pane,
336
+ '#{cursor_x},#{cursor_y},#{cursor_flag},#{history_size},#{pane_height}'], options);
337
+ return { captured: output.endsWith('\n') ? output.slice(0, -1) : output, cursor: parseTmuxCursorInfo(cursor) };
338
+ }
339
+
340
+ function parseTmuxCursorInfo(out) {
341
+ const [x, y, flag, hist, height] = out.trim().split(',').map((n) => Number.parseInt(n, 10));
342
+ return {
343
+ x: Number.isFinite(x) ? x : 0,
344
+ y: Number.isFinite(y) ? y : 0,
345
+ visible: flag !== 0,
346
+ history: Number.isFinite(hist) ? hist : 0,
347
+ height: Number.isFinite(height) && height > 0 ? height : 1
348
+ };
349
+ }
350
+
287
351
  // Read the real cursor cell + screen geometry from tmux so the browser mirror
288
352
  // can draw the input cursor at the right place. Returns null if unavailable.
289
353
  export function tmuxCursorInfo(pane) {
290
354
  try {
291
355
  const out = runTmux(['display-message', '-p', '-t', pane,
292
356
  '#{cursor_x},#{cursor_y},#{cursor_flag},#{history_size},#{pane_height}']);
293
- const [x, y, flag, hist, height] = out.trim().split(',').map((n) => Number.parseInt(n, 10));
294
- return {
295
- x: Number.isFinite(x) ? x : 0,
296
- y: Number.isFinite(y) ? y : 0,
297
- visible: flag !== 0,
298
- history: Number.isFinite(hist) ? hist : 0,
299
- height: Number.isFinite(height) && height > 0 ? height : 1
300
- };
357
+ return parseTmuxCursorInfo(out);
301
358
  } catch {
302
359
  return null;
303
360
  }
@@ -274,7 +274,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
274
274
  // refreshPeerIoHeartbeat comes from createTmuxSessions below; wrap it so the
275
275
  // reference resolves at call time instead of at this forward-passing site.
276
276
  const lazyRefreshPeerIoHeartbeat = (session) => refreshPeerIoHeartbeat(session);
277
- const { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream } = createTmuxStream({ broadcast, now, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, bufferDirectory, withBufferDirectoryLease, shellQuoteArg, ctx, isStopping });
277
+ const { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, refreshTmuxSnapshotAsync, invalidateTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream } = createTmuxStream({ broadcast, now, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, bufferDirectory, withBufferDirectoryLease, shellQuoteArg, ctx, isStopping });
278
278
  const prepared = await prepareLocalBus(ctx, opts);
279
279
 
280
280
  const {
@@ -390,7 +390,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
390
390
  ctx, sessions, isStopping,
391
391
  broadcast, closeSessionClients, hasOpenClients,
392
392
  sessionKey, sessionsForProject, resolveSessionPeerId,
393
- startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
393
+ startTmuxStream, stopTmuxStream, refreshTmuxSnapshotAsync, invalidateTmuxSnapshot,
394
394
  connectWebProject, nextProjectSessionId, getSession,
395
395
  runtimeProjectContexts,
396
396
  scanExternalSessions, adoptExternalSession, removeExternalBufferFiles,
@@ -85,7 +85,7 @@ export function createTmuxSessions(deps) {
85
85
  ctx, sessions, isStopping = () => false,
86
86
  broadcast, closeSessionClients, hasOpenClients,
87
87
  sessionKey, sessionsForProject, resolveSessionPeerId,
88
- startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
88
+ startTmuxStream, stopTmuxStream, refreshTmuxSnapshotAsync, invalidateTmuxSnapshot,
89
89
  connectWebProject, nextProjectSessionId, getSession,
90
90
  runtimeProjectContexts,
91
91
  scanExternalSessions, adoptExternalSession, removeExternalBufferFiles,
@@ -873,10 +873,11 @@ export function createTmuxSessions(deps) {
873
873
 
874
874
  function scheduleTmuxInputRefresh(session) {
875
875
  if (isStopping() || session.type !== 'tmux' || !session.pane) return;
876
+ invalidateTmuxSnapshot(session);
876
877
  if (session.inputRefreshTimer) return;
877
878
  session.inputRefreshTimer = setTimeout(() => {
878
879
  session.inputRefreshTimer = null;
879
- if (!isStopping() && session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
880
+ if (!isStopping() && session.status === 'running') void refreshTmuxSnapshotAsync(session);
880
881
  }, 80);
881
882
  }
882
883
 
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { performance } from 'node:perf_hooks';
9
- import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload } from '../terminal/tmux.mjs';
9
+ import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload, tmuxDisplaySnapshot } from '../terminal/tmux.mjs';
10
10
  import { CliError } from '../shared/errors.mjs';
11
11
  import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
12
12
 
@@ -17,8 +17,28 @@ export function createTmuxStream({
17
17
  withBufferDirectoryLease,
18
18
  shellQuoteArg,
19
19
  ctx,
20
- isStopping = () => false
20
+ isStopping = () => false,
21
+ snapshotTimeoutMs = 5000
21
22
  }) {
23
+ const snapshots = new WeakMap();
24
+ function snapshotState(session) {
25
+ let state = snapshots.get(session);
26
+ if (!state) {
27
+ state = { generation: 0, revision: 0, enabled: true, flight: null, dirty: false };
28
+ snapshots.set(session, state);
29
+ }
30
+ return state;
31
+ }
32
+ function invalidateSnapshot(session, enabled) {
33
+ const state = snapshotState(session);
34
+ state.generation++;
35
+ state.enabled = enabled;
36
+ state.dirty = false;
37
+ state.flight?.controller.abort();
38
+ }
39
+ function invalidateTmuxSnapshot(session) {
40
+ snapshotState(session).revision++;
41
+ }
22
42
  function cursorEscape(payload) {
23
43
  if (!payload) return '';
24
44
  return '\x1b[' + (payload.row + 1) + ';' + (payload.col + 1) + 'H' +
@@ -33,6 +53,7 @@ export function createTmuxStream({
33
53
 
34
54
  function refreshTmuxSnapshot(session) {
35
55
  if (session.type !== 'tmux' || !session.pane) return session.buffer || '';
56
+ snapshotState(session).revision++;
36
57
  try {
37
58
  session.buffer = tmuxSnapshot(session);
38
59
  } catch {
@@ -41,19 +62,60 @@ export function createTmuxStream({
41
62
  return session.buffer || '';
42
63
  }
43
64
 
65
+ // Display refreshes share one flight per session. A later input/resize
66
+ // invalidates that image and coalesces into one follow-up, never a queue.
67
+ function refreshTmuxSnapshotAsync(session, { invalidate = false } = {}) {
68
+ const state = snapshotState(session);
69
+ const active = () => state.enabled && !isStopping() && session.type === 'tmux' &&
70
+ session.status === 'running' && Boolean(session.pane);
71
+ if (!active()) return Promise.resolve(false);
72
+ if (invalidate) state.revision++;
73
+ if (state.flight) {
74
+ if (state.flight.revision !== state.revision || state.flight.pane !== session.pane || state.flight.generation !== state.generation) state.dirty = true;
75
+ return state.flight.promise;
76
+ }
77
+ const flight = { pane: session.pane, generation: state.generation, revision: state.revision,
78
+ controller: new globalThis.AbortController(), promise: null };
79
+ state.flight = flight;
80
+ flight.promise = (async () => {
81
+ let published = false;
82
+ try {
83
+ const { captured, cursor } = await tmuxDisplaySnapshot(flight.pane,
84
+ { timeoutMs: snapshotTimeoutMs, signal: flight.controller.signal });
85
+ if (active() && state.generation === flight.generation && state.revision === flight.revision &&
86
+ session.pane === flight.pane && !flight.controller.signal.aborted) {
87
+ session.buffer = captured + cursorEscape(tmuxCursorPayload(captured, cursor));
88
+ state.revision++;
89
+ session.lastBroadcastTime = Date.now();
90
+ broadcast(session, { type: 'replace', data: session.buffer });
91
+ published = true;
92
+ }
93
+ } catch { /* Keep the last image on timeout, cancellation or tmux errors. */ }
94
+ finally { if (state.flight === flight) state.flight = null; }
95
+ if (state.dirty && active()) {
96
+ state.dirty = false;
97
+ return refreshTmuxSnapshotAsync(session);
98
+ }
99
+ return published;
100
+ })();
101
+ return flight.promise;
102
+ }
103
+
44
104
  function scheduleTmuxReplace(session) {
45
105
  if (isStopping() || session.type !== 'tmux' || !session.pane) return;
106
+ invalidateTmuxSnapshot(session);
46
107
  if (session.replaceTimer) clearTimeout(session.replaceTimer);
47
108
  session.replaceTimer = setTimeout(() => {
48
109
  session.replaceTimer = null;
49
110
  if (isStopping()) return;
50
- session.lastBroadcastTime = Date.now();
51
- broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
111
+ void refreshTmuxSnapshotAsync(session);
52
112
  }, 80);
53
113
  }
54
114
 
55
115
  function startTmuxReplacePoller(session, warning = null) {
56
116
  if (isStopping()) return;
117
+ const state = snapshotState(session);
118
+ if (!state.enabled) invalidateSnapshot(session, true);
57
119
  if (session.replacePoller) clearInterval(session.replacePoller);
58
120
  if (warning) {
59
121
  session.warning = {
@@ -66,13 +128,14 @@ export function createTmuxStream({
66
128
  if (isStopping() || session.status !== 'running') return;
67
129
  if (Date.now() - (session.lastBroadcastTime || 0) > 4000) {
68
130
  session.lastBroadcastTime = Date.now();
69
- broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
131
+ void refreshTmuxSnapshotAsync(session);
70
132
  }
71
133
  }, 1600);
72
134
  }
73
135
 
74
136
  function startTmuxStream(session) {
75
137
  if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
138
+ invalidateSnapshot(session, true);
76
139
  const safePane = String(session.pane).replace(/[^A-Za-z0-9_-]/g, '');
77
140
  const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
78
141
  const streamDirectory = ensurePrivateProjectBufferDirectory((session.ctx || ctx).root);
@@ -131,6 +194,7 @@ export function createTmuxStream({
131
194
  }
132
195
  if (!chunks.length) return;
133
196
  const data = Buffer.concat(chunks).toString();
197
+ snapshotState(session).revision++;
134
198
  session.buffer += data;
135
199
  if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
136
200
  session.lastBroadcastTime = Date.now();
@@ -149,6 +213,7 @@ export function createTmuxStream({
149
213
  }
150
214
 
151
215
  function stopTmuxStream(session, { shutdownDeadline = null } = {}) {
216
+ invalidateSnapshot(session, false);
152
217
  const shuttingDown = Number.isFinite(shutdownDeadline);
153
218
  const remaining = () => shuttingDown ? shutdownDeadline - performance.now() : Infinity;
154
219
  if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
@@ -189,5 +254,5 @@ export function createTmuxStream({
189
254
  }
190
255
  }
191
256
 
192
- return { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream };
257
+ return { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, refreshTmuxSnapshotAsync, invalidateTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream };
193
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.1.0-rc.7",
3
+ "version": "1.1.0-rc.8",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7127,9 +7127,10 @@ async function syntaxAndHelp() {
7127
7127
  // skip the old hccSource-based migration fanout check (code moved to lib/db/connection.mjs)
7128
7128
  const migrationFanoutSource = '';
7129
7129
  const tmuxStreamSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'tmux-stream.mjs'), 'utf8');
7130
+ // Display refresh responsiveness and stale-frame suppression are exercised
7131
+ // behaviorally in tmux-display-http/refresh.test.mjs.
7130
7132
  for (const expected of [
7131
- "runTmux(['pipe-pane', '-t', session.pane]);",
7132
- "broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });"
7133
+ "runTmux(['pipe-pane', '-t', session.pane]);"
7133
7134
  ]) {
7134
7135
  if (!tmuxStreamSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
7135
7136
  }