@bramblex/codex-workbench 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -111,7 +111,7 @@ Most commands accept a full session id, a unique prefix, a saved name, or a sess
111
111
 
112
112
  ## Interactive UI
113
113
 
114
- The UI groups sessions by source and working directory, with sources/projects on the left, sessions on the upper right, and details below. When you start or resume a session, Codex temporarily takes over the terminal; when Codex exits, codex-workbench redraws the UI.
114
+ The UI groups sessions by source and working directory, with sources/projects on the left, sessions on the upper right, and details below. Local sessions render immediately, while remote SSH sources load in the background. When you start or resume a session, Codex temporarily takes over the terminal; when Codex exits, codex-workbench redraws the UI.
115
115
 
116
116
  Common keys:
117
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bramblex/codex-workbench",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Terminal workbench for browsing and managing local and SSH Codex sessions.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,7 +5,7 @@ const { createChildDirectory, listDirectories } = require('../model/directories'
5
5
  const { listSessions, updateMetadata } = require('../model/session-store');
6
6
  const { listServers } = require('../model/workbench-config');
7
7
  const { runCodexCommand, runNewCodexSession, usableCwd } = require('./codex-runner');
8
- const { runRemoteCwb, runRemoteCwbJson } = require('./ssh-runner');
8
+ const { runRemoteCwb, runRemoteCwbJson, runRemoteCwbJsonAsync } = require('./ssh-runner');
9
9
 
10
10
  const LOCAL_SOURCE = {
11
11
  id: 'local',
@@ -41,9 +41,26 @@ function configuredSources() {
41
41
  return [LOCAL_SOURCE, ...listServers().map(sourceForServer)];
42
42
  }
43
43
 
44
+ function sortSessions(sessions) {
45
+ sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
46
+ return sessions;
47
+ }
48
+
49
+ function loadLocalWorkbenchSessions(sources = configuredSources()) {
50
+ const sessions = listSessions().map((session) => attachSource(session, LOCAL_SOURCE));
51
+ return { errors: [], sessions, sources };
52
+ }
53
+
54
+ function loadRemoteSourceSessions(source) {
55
+ return runRemoteCwbJsonAsync(source, ['list', '--json', '--compact']).then((remoteSessions) => {
56
+ if (!Array.isArray(remoteSessions)) throw new Error('remote list did not return an array');
57
+ return remoteSessions.map((session) => attachSource(session, source));
58
+ });
59
+ }
60
+
44
61
  function loadWorkbenchSessions() {
45
62
  const sources = configuredSources();
46
- const sessions = listSessions().map((session) => attachSource(session, LOCAL_SOURCE));
63
+ const sessions = loadLocalWorkbenchSessions(sources).sessions;
47
64
  const errors = [];
48
65
 
49
66
  for (const source of sources.filter((candidate) => candidate.remote)) {
@@ -56,7 +73,7 @@ function loadWorkbenchSessions() {
56
73
  }
57
74
  }
58
75
 
59
- sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
76
+ sortSessions(sessions);
60
77
  return { errors, sessions, sources };
61
78
  }
62
79
 
@@ -139,6 +156,8 @@ module.exports = {
139
156
  configuredSources,
140
157
  createSourceDirectory,
141
158
  listSourceDirectories,
159
+ loadLocalWorkbenchSessions,
160
+ loadRemoteSourceSessions,
142
161
  loadWorkbenchSessions,
143
162
  runSourceNewSession,
144
163
  runSourceSessionCommand,
@@ -1,8 +1,10 @@
1
1
  'use strict';
2
2
 
3
- const { spawnSync } = require('child_process');
3
+ const { spawn, spawnSync } = require('child_process');
4
4
  const { shellQuote } = require('./codex-runner');
5
5
 
6
+ const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
7
+
6
8
  function sshBaseArgs(server, opts = {}) {
7
9
  const args = [];
8
10
  if (opts.tty) args.push('-t');
@@ -21,11 +23,59 @@ function runRemoteCwb(server, argv, opts = {}) {
21
23
  return spawnSync('ssh', args, {
22
24
  encoding: opts.encoding,
23
25
  env: process.env,
24
- maxBuffer: opts.maxBuffer || 64 * 1024 * 1024,
26
+ maxBuffer: opts.maxBuffer || DEFAULT_MAX_BUFFER,
25
27
  stdio: opts.stdio || (opts.encoding ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
26
28
  });
27
29
  }
28
30
 
31
+ function runRemoteCwbAsync(server, argv, opts = {}) {
32
+ const command = remoteCwbCommand(server, argv);
33
+ const args = [...sshBaseArgs(server, { tty: opts.tty }), command];
34
+ const maxBuffer = opts.maxBuffer || DEFAULT_MAX_BUFFER;
35
+ const encoding = opts.encoding || 'utf8';
36
+
37
+ return new Promise((resolve) => {
38
+ const child = spawn('ssh', args, {
39
+ env: process.env,
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ });
42
+ let stdout = '';
43
+ let stderr = '';
44
+ let stdoutSize = 0;
45
+ let stderrSize = 0;
46
+ let settled = false;
47
+
48
+ const finish = (result) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ resolve(result);
52
+ };
53
+
54
+ const append = (name, chunk) => {
55
+ const text = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : String(chunk);
56
+ const size = Buffer.byteLength(text);
57
+ if (name === 'stdout') {
58
+ stdout += text;
59
+ stdoutSize += size;
60
+ } else {
61
+ stderr += text;
62
+ stderrSize += size;
63
+ }
64
+ if (stdoutSize + stderrSize > maxBuffer) {
65
+ child.kill();
66
+ const error = new Error('spawn ssh ENOBUFS');
67
+ error.code = 'ENOBUFS';
68
+ finish({ error, stdout, stderr, status: null, signal: 'SIGTERM' });
69
+ }
70
+ };
71
+
72
+ child.stdout.on('data', (chunk) => append('stdout', chunk));
73
+ child.stderr.on('data', (chunk) => append('stderr', chunk));
74
+ child.on('error', (error) => finish({ error, stdout, stderr, status: null, signal: null }));
75
+ child.on('close', (status, signal) => finish({ stdout, stderr, status, signal }));
76
+ });
77
+ }
78
+
29
79
  function runRemoteCwbJson(server, argv) {
30
80
  const result = runRemoteCwb(server, argv, { encoding: 'utf8' });
31
81
  if (result.error) {
@@ -41,9 +91,26 @@ function runRemoteCwbJson(server, argv) {
41
91
  return JSON.parse(result.stdout || 'null');
42
92
  }
43
93
 
94
+ async function runRemoteCwbJsonAsync(server, argv) {
95
+ const result = await runRemoteCwbAsync(server, argv, { encoding: 'utf8' });
96
+ if (result.error) {
97
+ if (result.error.code === 'ENOBUFS') {
98
+ throw new Error('remote output exceeded buffer; update the remote codex-workbench so compact listing is available');
99
+ }
100
+ throw result.error;
101
+ }
102
+ if (result.status !== 0) {
103
+ const stderr = (result.stderr || '').trim();
104
+ throw new Error(stderr || `ssh exited with code ${result.status}`);
105
+ }
106
+ return JSON.parse(result.stdout || 'null');
107
+ }
108
+
44
109
  module.exports = {
45
110
  remoteCwbCommand,
46
111
  runRemoteCwb,
112
+ runRemoteCwbAsync,
47
113
  runRemoteCwbJson,
114
+ runRemoteCwbJsonAsync,
48
115
  sshBaseArgs,
49
116
  };
@@ -9,6 +9,8 @@ const {
9
9
  LOCAL_SOURCE,
10
10
  createSourceDirectory,
11
11
  listSourceDirectories,
12
+ loadLocalWorkbenchSessions,
13
+ loadRemoteSourceSessions,
12
14
  loadWorkbenchSessions,
13
15
  runSourceNewSession,
14
16
  runSourceSessionCommand,
@@ -34,6 +36,9 @@ async function runWorkbench() {
34
36
  let syncingProjects = false;
35
37
  let projectWidth = 32;
36
38
  let activePanel = 'projects';
39
+ let remoteLoadId = 0;
40
+ let remoteLoading = false;
41
+ let closed = false;
37
42
 
38
43
  const screen = blessed.screen({
39
44
  smartCSR: true,
@@ -159,6 +164,17 @@ async function runWorkbench() {
159
164
 
160
165
  const currentGroup = () => groups[groupIndex] || groups[0] || { kind: 'all', source: null, cwd: null };
161
166
 
167
+ const groupKey = (group) => {
168
+ if (!group || group.kind === 'all') return 'all';
169
+ if (group.kind === 'source') return `source:${group.source.id}`;
170
+ return `project:${group.source.id}:${group.cwd}`;
171
+ };
172
+
173
+ const restoreGroupKey = (key) => {
174
+ const index = groups.findIndex((group) => groupKey(group) === key);
175
+ if (index !== -1) groupIndex = index;
176
+ };
177
+
162
178
  const currentSessions = () => {
163
179
  const group = currentGroup();
164
180
  if (group.kind === 'all') return sessions;
@@ -220,20 +236,101 @@ async function runWorkbench() {
220
236
  status.style.fg = isError ? 'red' : 'white';
221
237
  };
222
238
 
223
- const reload = () => {
224
- const state = loadWorkbenchSessions();
225
- sources = state.sources;
226
- sourceErrors = state.errors;
227
- sessions = state.sessions.filter((s) => !s.archived && !s.hidden);
239
+ const visibleSession = (session) => !session.archived && !session.hidden;
240
+
241
+ const sortSessionList = (list) => {
242
+ list.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
243
+ return list;
244
+ };
245
+
246
+ const setSourceErrorMessage = () => {
247
+ if (!sourceErrors.length) return false;
248
+ const first = sourceErrors[0];
249
+ const detail = `${first.source.label}: ${first.error}`;
250
+ const prefix = sourceErrors.length === 1 ? 'Remote source failed' : `${sourceErrors.length} remote sources failed`;
251
+ setMessage(`${prefix}: ${truncate(detail, 100)}`, true);
252
+ return true;
253
+ };
254
+
255
+ const updateSessionViews = (preferredGroupKey = groupKey(currentGroup())) => {
228
256
  groups = buildGroups();
257
+ restoreGroupKey(preferredGroupKey);
229
258
  if (groupIndex >= groups.length) groupIndex = Math.max(0, groups.length - 1);
230
259
  const visible = currentSessions();
231
260
  if (selected >= visible.length) selected = Math.max(0, visible.length - 1);
232
- if (sourceErrors.length && (!message || message === 'Ready')) {
233
- const first = sourceErrors[0];
234
- const detail = `${first.source.label}: ${first.error}`;
235
- const prefix = sourceErrors.length === 1 ? 'Remote source failed' : `${sourceErrors.length} remote sources failed`;
236
- setMessage(`${prefix}: ${truncate(detail, 100)}`, true);
261
+ };
262
+
263
+ const reloadLocal = (preserveRemote = true) => {
264
+ const preferredGroupKey = groupKey(currentGroup());
265
+ const state = loadLocalWorkbenchSessions();
266
+ const sourceIds = new Set(state.sources.map((source) => source.id));
267
+ const remoteSessions = preserveRemote
268
+ ? sessions.filter((session) => session.sourceRemote && sourceIds.has(session.sourceId))
269
+ : [];
270
+ sources = state.sources;
271
+ sourceErrors = sourceErrors.filter((item) => sourceIds.has(item.source.id));
272
+ sessions = sortSessionList([...state.sessions, ...remoteSessions].filter(visibleSession));
273
+ updateSessionViews(preferredGroupKey);
274
+ };
275
+
276
+ const replaceSourceSessions = (source, sourceSessions) => {
277
+ const preferredGroupKey = groupKey(currentGroup());
278
+ sessions = sortSessionList([
279
+ ...sessions.filter((session) => session.sourceId !== source.id),
280
+ ...sourceSessions.filter(visibleSession),
281
+ ]);
282
+ updateSessionViews(preferredGroupKey);
283
+ };
284
+
285
+ const renderRemoteUpdate = () => {
286
+ if (closed) return;
287
+ syncProjects();
288
+ syncList();
289
+ render();
290
+ };
291
+
292
+ const startRemoteReload = (quiet = false) => {
293
+ const remoteSources = sources.filter((source) => source.remote);
294
+ remoteLoadId += 1;
295
+ const loadId = remoteLoadId;
296
+ sourceErrors = [];
297
+ if (!remoteSources.length) {
298
+ remoteLoading = false;
299
+ return;
300
+ }
301
+
302
+ remoteLoading = true;
303
+ let completed = 0;
304
+ if (!quiet && (!message || message === 'Ready')) {
305
+ setMessage(`Loading ${remoteSources.length} remote source${remoteSources.length === 1 ? '' : 's'}...`);
306
+ renderRemoteUpdate();
307
+ }
308
+
309
+ for (const source of remoteSources) {
310
+ loadRemoteSourceSessions(source)
311
+ .then((sourceSessions) => {
312
+ if (closed || loadId !== remoteLoadId) return;
313
+ replaceSourceSessions(source, sourceSessions);
314
+ })
315
+ .catch((err) => {
316
+ if (closed || loadId !== remoteLoadId) return;
317
+ sourceErrors.push({ source, error: err.message });
318
+ })
319
+ .finally(() => {
320
+ if (closed || loadId !== remoteLoadId) return;
321
+ completed += 1;
322
+ remoteLoading = completed < remoteSources.length;
323
+ if (remoteLoading) {
324
+ if (!quiet && message.startsWith('Loading ')) {
325
+ setMessage(`Loading remote sources... ${completed}/${remoteSources.length}`);
326
+ }
327
+ } else if (sourceErrors.length) {
328
+ setSourceErrorMessage();
329
+ } else if (message.startsWith('Loading ')) {
330
+ setMessage('Remote sources loaded.');
331
+ }
332
+ renderRemoteUpdate();
333
+ });
237
334
  }
238
335
  };
239
336
 
@@ -356,12 +453,13 @@ async function runWorkbench() {
356
453
  const promptOpen = () => prompt.visible || question.visible || directoryPicker.isOpen();
357
454
 
358
455
  const leaveScreen = () => {
456
+ closed = true;
359
457
  screen.destroy();
360
458
  };
361
459
 
362
460
  const refreshAfterAction = (text, isError = false, focusCwd = null, focusSourceId = null) => {
363
461
  setMessage(text, isError);
364
- reload();
462
+ reloadLocal();
365
463
  if (focusCwd) {
366
464
  const nextGroupIndex = groups.findIndex((group) => {
367
465
  return group.kind === 'project' &&
@@ -373,6 +471,7 @@ async function runWorkbench() {
373
471
  syncProjects();
374
472
  syncList();
375
473
  render();
474
+ startRemoteReload(true);
376
475
  };
377
476
 
378
477
  const selectGroup = (index) => {
@@ -453,8 +552,9 @@ async function runWorkbench() {
453
552
  }
454
553
  };
455
554
 
456
- reload();
457
- if (!sourceErrors.length) setMessage('Ready');
555
+ reloadLocal(false);
556
+ const remoteSourceCount = sources.filter((source) => source.remote).length;
557
+ setMessage(remoteSourceCount ? `Loading ${remoteSourceCount} remote source${remoteSourceCount === 1 ? '' : 's'}...` : 'Ready');
458
558
  applyLayout();
459
559
  syncProjects();
460
560
  syncList();
@@ -648,6 +748,7 @@ async function runWorkbench() {
648
748
 
649
749
  projectsList.focus();
650
750
  render();
751
+ startRemoteReload(true);
651
752
 
652
753
  return new Promise(() => {});
653
754
  }