@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 +18 -0
- package/lib/terminal/tmux.mjs +66 -9
- package/lib/web/runtime-main.mjs +2 -2
- package/lib/web/tmux-sessions.mjs +3 -2
- package/lib/web/tmux-stream.mjs +71 -6
- package/package.json +1 -1
- package/scripts/regression.mjs +3 -2
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
|
package/lib/terminal/tmux.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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')
|
|
880
|
+
if (!isStopping() && session.status === 'running') void refreshTmuxSnapshotAsync(session);
|
|
880
881
|
}, 80);
|
|
881
882
|
}
|
|
882
883
|
|
package/lib/web/tmux-stream.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/scripts/regression.mjs
CHANGED
|
@@ -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
|
}
|