@bramblex/codex-workbench 0.1.4 → 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
@@ -40,7 +40,7 @@ codex-workbench list
40
40
  ```sh
41
41
  codex-workbench [ui]
42
42
  codex-workbench doctor
43
- codex-workbench list [--json] [--cwd <dir>] [--all]
43
+ codex-workbench list [--json] [--compact] [--cwd <dir>] [--all]
44
44
  codex-workbench show <session>
45
45
  codex-workbench rename <session> <name>
46
46
  codex-workbench note <session> <note>
@@ -73,10 +73,13 @@ Use `list` to find sessions:
73
73
  ```sh
74
74
  codex-workbench list
75
75
  codex-workbench list --json
76
+ codex-workbench list --json --compact
76
77
  codex-workbench list --cwd /path/to/project
77
78
  codex-workbench list --all
78
79
  ```
79
80
 
81
+ Use `--compact` with `--json` when another tool only needs session summaries. It omits the full message history and keeps remote SSH listings small.
82
+
80
83
  Use `new` to start a fresh Codex session in a project directory:
81
84
 
82
85
  ```sh
@@ -108,7 +111,7 @@ Most commands accept a full session id, a unique prefix, a saved name, or a sess
108
111
 
109
112
  ## Interactive UI
110
113
 
111
- 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.
112
115
 
113
116
  Common keys:
114
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bramblex/codex-workbench",
3
- "version": "0.1.4",
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": {
package/src/cli-output.js CHANGED
@@ -10,7 +10,7 @@ function usage() {
10
10
  Usage:
11
11
  codex-workbench [ui]
12
12
  codex-workbench doctor
13
- codex-workbench list [--json] [--cwd <dir>] [--all]
13
+ codex-workbench list [--json] [--compact] [--cwd <dir>] [--all]
14
14
  codex-workbench show <session>
15
15
  codex-workbench rename <session> <name>
16
16
  codex-workbench note <session> <note>
@@ -41,7 +41,8 @@ function printList(sessions, opts = {}) {
41
41
  return true;
42
42
  });
43
43
  if (opts.json) {
44
- console.log(JSON.stringify(filtered, null, 2));
44
+ const payload = opts.compact ? filtered.map(compactSession) : filtered;
45
+ console.log(JSON.stringify(payload, null, 2));
45
46
  return;
46
47
  }
47
48
  const groups = new Map();
@@ -62,6 +63,11 @@ function printList(sessions, opts = {}) {
62
63
  if (!filtered.length) console.log('No sessions found.');
63
64
  }
64
65
 
66
+ function compactSession(session) {
67
+ const { messages, ...compact } = session;
68
+ return compact;
69
+ }
70
+
65
71
  function printShow(session) {
66
72
  console.log(`${session.name || '(unnamed)'} ${session.archived ? '[archived]' : ''}${session.hidden ? '[hidden]' : ''}`);
67
73
  console.log(`id: ${session.id}`);
@@ -73,7 +79,7 @@ function printShow(session) {
73
79
  console.log(`turns: ${session.turns}`);
74
80
  if (session.note) console.log(`note: ${session.note}`);
75
81
  console.log('\nMessages:');
76
- for (const msg of session.messages) {
82
+ for (const msg of session.messages || []) {
77
83
  if (msg.role === 'developer') continue;
78
84
  const prefix = msg.role === 'assistant' ? 'A' : msg.role === 'user' ? 'U' : msg.role.slice(0, 1).toUpperCase();
79
85
  console.log(` ${prefix}: ${truncate(msg.text, 180)}`);
@@ -102,6 +108,7 @@ function printDoctor() {
102
108
  }
103
109
 
104
110
  module.exports = {
111
+ compactSession,
105
112
  printDoctor,
106
113
  printList,
107
114
  printShow,
package/src/cli.js CHANGED
@@ -27,6 +27,7 @@ function parseFlags(args) {
27
27
  const arg = args[i];
28
28
  if (arg === '--json') out.json = true;
29
29
  else if (arg === '--all') out.all = true;
30
+ else if (arg === '--compact') out.compact = true;
30
31
  else if (arg === '--force') out.force = true;
31
32
  else if (arg === '--file') out.file = true;
32
33
  else if (arg === '--cwd') {
@@ -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,14 +41,31 @@ 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)) {
50
67
  try {
51
- const remoteSessions = runRemoteCwbJson(source, ['list', '--json']);
68
+ const remoteSessions = runRemoteCwbJson(source, ['list', '--json', '--compact']);
52
69
  if (!Array.isArray(remoteSessions)) throw new Error('remote list did not return an array');
53
70
  sessions.push(...remoteSessions.map((session) => attachSource(session, source)));
54
71
  } catch (err) {
@@ -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,13 +23,82 @@ function runRemoteCwb(server, argv, opts = {}) {
21
23
  return spawnSync('ssh', args, {
22
24
  encoding: opts.encoding,
23
25
  env: process.env,
26
+ maxBuffer: opts.maxBuffer || DEFAULT_MAX_BUFFER,
24
27
  stdio: opts.stdio || (opts.encoding ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
25
28
  });
26
29
  }
27
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
+
28
79
  function runRemoteCwbJson(server, argv) {
29
80
  const result = runRemoteCwb(server, argv, { encoding: 'utf8' });
30
- if (result.error) throw result.error;
81
+ if (result.error) {
82
+ if (result.error.code === 'ENOBUFS') {
83
+ throw new Error('remote output exceeded buffer; update the remote codex-workbench so compact listing is available');
84
+ }
85
+ throw result.error;
86
+ }
87
+ if (result.status !== 0) {
88
+ const stderr = (result.stderr || '').trim();
89
+ throw new Error(stderr || `ssh exited with code ${result.status}`);
90
+ }
91
+ return JSON.parse(result.stdout || 'null');
92
+ }
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
+ }
31
102
  if (result.status !== 0) {
32
103
  const stderr = (result.stderr || '').trim();
33
104
  throw new Error(stderr || `ssh exited with code ${result.status}`);
@@ -38,6 +109,8 @@ function runRemoteCwbJson(server, argv) {
38
109
  module.exports = {
39
110
  remoteCwbCommand,
40
111
  runRemoteCwb,
112
+ runRemoteCwbAsync,
41
113
  runRemoteCwbJson,
114
+ runRemoteCwbJsonAsync,
42
115
  sshBaseArgs,
43
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
  }