@bramblex/codex-workbench 0.1.13 → 0.1.15

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.
@@ -2,118 +2,44 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { META_PATH, SESSIONS_DIR } = require('../config');
5
+ const { loadMeta, removeMetadata, updateMetadata } = require('./metadata');
6
+ const { getAllSessionFiles, getProvider } = require('../providers');
6
7
 
7
- function readJson(file, fallback) {
8
- try {
9
- return JSON.parse(fs.readFileSync(file, 'utf8'));
10
- } catch {
11
- return fallback;
12
- }
13
- }
14
-
15
- function writeJson(file, value) {
16
- fs.mkdirSync(path.dirname(file), { recursive: true });
17
- fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
18
- }
19
-
20
- function walk(dir, out = []) {
21
- let entries = [];
22
- try {
23
- entries = fs.readdirSync(dir, { withFileTypes: true });
24
- } catch {
25
- return out;
26
- }
27
- for (const entry of entries) {
28
- const full = path.join(dir, entry.name);
29
- if (entry.isDirectory()) walk(full, out);
30
- else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
31
- }
32
- return out;
33
- }
8
+ // ---------------------------------------------------------------------------
9
+ // Metadata persistence (provider-agnostic: keyed by session id)
10
+ // ---------------------------------------------------------------------------
34
11
 
35
- function textFromContent(content) {
36
- if (!Array.isArray(content)) return '';
37
- return content
38
- .filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
39
- .map((item) => item.text || '')
40
- .join(' ')
41
- .replace(/\s+/g, ' ')
42
- .trim();
43
- }
44
-
45
- function isNoiseUserText(text) {
46
- return text.includes('<environment_context>') || text.includes('<permissions instructions>');
47
- }
12
+ // ---------------------------------------------------------------------------
13
+ // Session listing (aggregates across all providers)
14
+ // ---------------------------------------------------------------------------
48
15
 
49
- function parseSession(file) {
50
- const stat = fs.statSync(file);
51
- const raw = fs.readFileSync(file, 'utf8').trim();
52
- const lines = raw ? raw.split(/\n/) : [];
53
- let meta = {};
54
- const messages = [];
55
- let turns = 0;
16
+ function listSessions() {
17
+ const meta = loadMeta();
18
+ const fileEntries = getAllSessionFiles();
56
19
 
57
- for (const line of lines) {
58
- let row;
20
+ const sessions = [];
21
+ for (const { file, backend } of fileEntries) {
59
22
  try {
60
- row = JSON.parse(line);
61
- } catch {
62
- continue;
63
- }
64
- if (row.type === 'session_meta') meta = row.payload || {};
65
- if (row.type === 'response_item' && row.payload && row.payload.type === 'message') {
66
- const msg = row.payload;
67
- if (msg.role === 'developer') continue;
68
- const text = textFromContent(msg.content);
69
- if (!text) continue;
70
- if (msg.role === 'user' && isNoiseUserText(text)) continue;
71
- messages.push({
72
- role: msg.role,
73
- phase: msg.phase || '',
74
- text,
75
- });
76
- if (msg.role === 'user') turns += 1;
23
+ const provider = getProvider(backend);
24
+ const session = provider.parseSession(file);
25
+ // Merge workbench metadata (name, note, archived)
26
+ const custom = meta.sessions[session.id] || {};
27
+ sessions.push({ ...session, ...custom });
28
+ } catch (_err) {
29
+ // Skip unparseable files
77
30
  }
78
31
  }
79
32
 
80
- const id = meta.id || path.basename(file, '.jsonl').split('-').slice(-5).join('-');
81
- const firstUser = messages.find((msg) => msg.role === 'user');
82
- const lastUser = [...messages].reverse().find((msg) => msg.role === 'user');
83
- const lastAssistant = [...messages].reverse().find((msg) => msg.role === 'assistant');
84
-
85
- return {
86
- id,
87
- file,
88
- cwd: meta.cwd || '(unknown)',
89
- startedAt: meta.timestamp || null,
90
- updatedAt: stat.mtime.toISOString(),
91
- cliVersion: meta.cli_version || '',
92
- source: meta.source || '',
93
- provider: meta.model_provider || '',
94
- turns,
95
- first: firstUser ? firstUser.text : '',
96
- last: lastUser ? lastUser.text : '',
97
- lastAssistant: lastAssistant ? lastAssistant.text : '',
98
- messages,
99
- };
33
+ sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
34
+ return sessions;
100
35
  }
101
36
 
102
- function loadMeta() {
103
- const data = readJson(META_PATH, { sessions: {} });
104
- if (!data.sessions) data.sessions = {};
105
- return data;
106
- }
107
-
108
- function listSessions() {
109
- const meta = loadMeta();
110
- return walk(SESSIONS_DIR)
111
- .map(parseSession)
112
- .map((session) => ({ ...session, ...(meta.sessions[session.id] || {}) }))
113
- .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
114
- }
37
+ // ---------------------------------------------------------------------------
38
+ // Session resolution
39
+ // ---------------------------------------------------------------------------
115
40
 
116
- function resolveSession(query, sessions = listSessions()) {
41
+ function resolveSession(query, sessions) {
42
+ if (!sessions) sessions = listSessions();
117
43
  if (!query) throw new Error('Missing session. Run `codex-workbench list` to find a session id.');
118
44
  const matches = sessions.filter((session) => {
119
45
  return session.id === query ||
@@ -123,21 +49,9 @@ function resolveSession(query, sessions = listSessions()) {
123
49
  });
124
50
  if (matches.length === 1) return matches[0];
125
51
  if (matches.length === 0) throw new Error(`No session matched: ${query}`);
126
- throw new Error(`Ambiguous session: ${query}\n${matches.map((s) => ` ${s.id} ${s.name || ''}`).join('\n')}`);
127
- }
128
-
129
- function updateMetadata(session, patch) {
130
- const meta = loadMeta();
131
- meta.sessions[session.id] = { ...(meta.sessions[session.id] || {}), ...patch };
132
- meta.updatedAt = new Date().toISOString();
133
- writeJson(META_PATH, meta);
134
- }
135
-
136
- function removeMetadata(session) {
137
- const meta = loadMeta();
138
- delete meta.sessions[session.id];
139
- meta.updatedAt = new Date().toISOString();
140
- writeJson(META_PATH, meta);
52
+ throw new Error(
53
+ `Ambiguous session: ${query}\n${matches.map((s) => ` ${s.id} ${s.name || ''}`).join('\n')}`
54
+ );
141
55
  }
142
56
 
143
57
  function deleteSessionFile(session) {
@@ -145,6 +59,15 @@ function deleteSessionFile(session) {
145
59
  removeMetadata(session);
146
60
  }
147
61
 
62
+ // ---------------------------------------------------------------------------
63
+ // Re-export parseSession for backward compat (uses codex provider by default)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function parseSession(file) {
67
+ const codex = getProvider('codex');
68
+ return codex.parseSession(file);
69
+ }
70
+
148
71
  module.exports = {
149
72
  deleteSessionFile,
150
73
  listSessions,
@@ -6,29 +6,65 @@ const { CONFIG_PATH } = require('../config');
6
6
  function readWorkbenchConfig(file = CONFIG_PATH) {
7
7
  try {
8
8
  const data = JSON.parse(fs.readFileSync(file, 'utf8'));
9
- return data && typeof data === 'object' ? data : {};
10
- } catch {
11
- return {};
9
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
10
+ throw new Error(`Workbench config must be a JSON object: ${file}`);
11
+ }
12
+ return data;
13
+ } catch (err) {
14
+ if (err && err.code === 'ENOENT') return {};
15
+ if (err instanceof SyntaxError) throw new Error(`Invalid workbench config JSON: ${file}`);
16
+ throw err;
12
17
  }
13
18
  }
14
19
 
20
+ function configError(index, message) {
21
+ return new Error(`Invalid server config at servers[${index}]: ${message}`);
22
+ }
23
+
24
+ function stringField(value, field, index, required = false) {
25
+ if (value === undefined || value === null || value === '') {
26
+ if (required) throw configError(index, `${field} is required`);
27
+ return '';
28
+ }
29
+ if (typeof value !== 'string') throw configError(index, `${field} must be a string`);
30
+ return value;
31
+ }
32
+
33
+ function normalizeSshArgs(value, index) {
34
+ if (value === undefined) return [];
35
+ if (!Array.isArray(value)) throw configError(index, 'sshArgs must be an array');
36
+ return value.map((item, argIndex) => {
37
+ if (typeof item !== 'string') {
38
+ throw configError(index, `sshArgs[${argIndex}] must be a string`);
39
+ }
40
+ return item;
41
+ });
42
+ }
43
+
15
44
  function normalizeServer(server, index) {
16
- const target = server.target || server.host || '';
17
- if (!target) return null;
18
- const id = server.id || target.replace(/[^a-zA-Z0-9_.-]+/g, '-');
45
+ if (!server || typeof server !== 'object' || Array.isArray(server)) {
46
+ throw configError(index, 'server must be an object');
47
+ }
48
+ const target = stringField(server.target || server.host, 'target', index, true);
49
+ const id = stringField(server.id, 'id', index) || target.replace(/[^a-zA-Z0-9_.-]+/g, '-');
50
+ const label = stringField(server.label || server.name, 'label', index) || id || `server-${index + 1}`;
51
+ const command = stringField(server.command, 'command', index) || 'cwb';
52
+ const sshArgs = normalizeSshArgs(server.sshArgs, index);
19
53
  return {
20
54
  id,
21
- label: server.label || server.name || id || `server-${index + 1}`,
55
+ label,
22
56
  target,
23
- command: server.command || 'cwb',
24
- sshArgs: Array.isArray(server.sshArgs) ? server.sshArgs.map(String) : [],
57
+ command,
58
+ sshArgs,
25
59
  };
26
60
  }
27
61
 
28
62
  function listServers(config = readWorkbenchConfig()) {
29
- return (Array.isArray(config.servers) ? config.servers : [])
30
- .map(normalizeServer)
31
- .filter(Boolean);
63
+ if (config.servers === undefined) return [];
64
+ if (!Array.isArray(config.servers)) {
65
+ throw new Error('Invalid workbench config: servers must be an array');
66
+ }
67
+ return config.servers.map(normalizeServer);
32
68
  }
33
69
 
34
70
  module.exports = {
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Codex provider – session parsing, binary discovery, and CLI operations
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { spawn, spawnSync } = require('child_process');
10
+ const { HOME } = require('../config');
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Binary discovery (extracted from codex-bin.js)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const DEFAULT_CODEX_BIN = '/Applications/Codex.app/Contents/Resources/codex';
17
+
18
+ function isExecutable(file) {
19
+ try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
20
+ }
21
+
22
+ function findOnPath(command, pathValue) {
23
+ for (const dir of (pathValue || process.env.PATH || '').split(path.delimiter)) {
24
+ if (!dir) continue;
25
+ const candidate = path.join(dir, command);
26
+ if (isExecutable(candidate)) return candidate;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function shellQuote(value) {
32
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
33
+ }
34
+
35
+ function executableFromOutput(output) {
36
+ for (const line of String(output || '').split(/\r?\n/)) {
37
+ const candidate = line.trim();
38
+ if (candidate && path.isAbsolute(candidate) && isExecutable(candidate)) return candidate;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function runShellLookup(shell, shellArgs, command, env) {
44
+ const result = spawnSync(shell, [...shellArgs, `command -v ${shellQuote(command)}`], {
45
+ encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'],
46
+ });
47
+ if (result.error || result.status !== 0) return null;
48
+ return executableFromOutput(result.stdout);
49
+ }
50
+
51
+ function findWithShell(command, env) {
52
+ const shell = (env || process.env).SHELL || '/bin/sh';
53
+ if (!isExecutable(shell)) return null;
54
+ return runShellLookup(shell, ['-lc'], command, env) ||
55
+ runShellLookup(shell, ['-ic'], command, env);
56
+ }
57
+
58
+ function resolveCodexBin() {
59
+ const env = process.env;
60
+
61
+ if (env.CODEX_BIN) {
62
+ if (isExecutable(env.CODEX_BIN)) return env.CODEX_BIN;
63
+ throw new Error(`CODEX_BIN is not executable: ${env.CODEX_BIN}`);
64
+ }
65
+
66
+ const fromLogin = findWithShell('codex', env);
67
+ if (fromLogin) return fromLogin;
68
+
69
+ const fromPath = findOnPath('codex', env.PATH);
70
+ if (fromPath) return fromPath;
71
+
72
+ if (DEFAULT_CODEX_BIN && isExecutable(DEFAULT_CODEX_BIN)) return DEFAULT_CODEX_BIN;
73
+
74
+ throw new Error('Could not find the codex executable. Set CODEX_BIN or add codex to your shell PATH.');
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // CLI execution (extracted from codex-runner.js)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function usableCwd(dir) {
82
+ for (const candidate of [dir, process.cwd(), HOME]) {
83
+ if (!candidate || candidate === '(unknown)') continue;
84
+ try { if (fs.statSync(candidate).isDirectory()) return candidate; } catch { /* skip */ }
85
+ }
86
+ return HOME;
87
+ }
88
+
89
+ function commandShell() {
90
+ const shell = process.env.SHELL || '/bin/sh';
91
+ try { fs.accessSync(shell, fs.constants.X_OK); return shell; } catch { return '/bin/sh'; }
92
+ }
93
+
94
+ function runArgv(argv, cwd, inherit) {
95
+ const shellCommand = `exec ${argv.map(shellQuote).join(' ')}`;
96
+ const shell = commandShell();
97
+ if (inherit) {
98
+ const child = spawn(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
99
+ child.on('error', (err) => { console.error(`error: failed to start codex: ${err.message}`); process.exit(1); });
100
+ child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); process.exit(code || 0); });
101
+ return undefined;
102
+ }
103
+ const result = spawnSync(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
104
+ if (result.error) throw new Error(`failed to start codex: ${result.error.message}`);
105
+ const status = typeof result.status === 'number' ? result.status : 1;
106
+ process.exitCode = status;
107
+ return status;
108
+ }
109
+
110
+ function runCommand(command, session, args, inherit) {
111
+ const executable = resolveCodexBin();
112
+ const argv = [executable, command, session.id, ...(args || [])];
113
+ return runArgv(argv, usableCwd(session.cwd), inherit);
114
+ }
115
+
116
+ function runNew(cwd, args, inherit) {
117
+ const executable = resolveCodexBin();
118
+ const argv = [executable, ...(args || [])];
119
+ return runArgv(argv, usableCwd(cwd), inherit);
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Session parsing (extracted from session-store.js)
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const CODEX_SESSIONS_DIR = process.env.CODEX_SESSIONS_DIR || path.join(
127
+ process.env.CODEX_HOME || path.join(HOME, '.codex'),
128
+ 'sessions'
129
+ );
130
+
131
+ function textFromContent(content) {
132
+ if (!Array.isArray(content)) return '';
133
+ return content
134
+ .filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
135
+ .map((item) => item.text || '')
136
+ .join(' ')
137
+ .replace(/\s+/g, ' ')
138
+ .trim();
139
+ }
140
+
141
+ function isNoiseUserText(text) {
142
+ return text.includes('<environment_context>') || text.includes('<permissions instructions>');
143
+ }
144
+
145
+ function walk(dir, out) {
146
+ out = out || [];
147
+ let entries;
148
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
149
+ for (const entry of entries) {
150
+ const full = path.join(dir, entry.name);
151
+ if (entry.isDirectory()) walk(full, out);
152
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function parseSession(file) {
158
+ const stat = fs.statSync(file);
159
+ const raw = fs.readFileSync(file, 'utf8').trim();
160
+ const lines = raw ? raw.split(/\n/) : [];
161
+ let meta = {};
162
+ const messages = [];
163
+ let turns = 0;
164
+
165
+ for (const line of lines) {
166
+ let row;
167
+ try { row = JSON.parse(line); } catch { continue; }
168
+ if (row.type === 'session_meta') meta = row.payload || {};
169
+ if (row.type === 'response_item' && row.payload && row.payload.type === 'message') {
170
+ const msg = row.payload;
171
+ if (msg.role === 'developer') continue;
172
+ const text = textFromContent(msg.content);
173
+ if (!text) continue;
174
+ if (msg.role === 'user' && isNoiseUserText(text)) continue;
175
+ messages.push({ role: msg.role, phase: msg.phase || '', text });
176
+ if (msg.role === 'user') turns += 1;
177
+ }
178
+ }
179
+
180
+ const id = meta.id || path.basename(file, '.jsonl').split('-').slice(-5).join('-');
181
+
182
+ return {
183
+ id,
184
+ file,
185
+ cwd: meta.cwd || '(unknown)',
186
+ startedAt: meta.timestamp || null,
187
+ updatedAt: stat.mtime.toISOString(),
188
+ cliVersion: meta.cli_version || '',
189
+ provider: meta.model_provider || '',
190
+ turns,
191
+ first: firstUserText(messages),
192
+ last: lastUserText(messages),
193
+ lastAssistant: lastAssistantText(messages),
194
+ messages,
195
+ backend: 'codex',
196
+ };
197
+ }
198
+
199
+ function firstUserText(messages) {
200
+ const m = messages.find((msg) => msg.role === 'user');
201
+ return m ? m.text : '';
202
+ }
203
+
204
+ function lastUserText(messages) {
205
+ for (let i = messages.length - 1; i >= 0; i--) {
206
+ if (messages[i].role === 'user') return messages[i].text;
207
+ }
208
+ return '';
209
+ }
210
+
211
+ function lastAssistantText(messages) {
212
+ for (let i = messages.length - 1; i >= 0; i--) {
213
+ if (messages[i].role === 'assistant') return messages[i].text;
214
+ }
215
+ return '';
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Provider interface
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function isAvailable() {
223
+ try { return fs.statSync(CODEX_SESSIONS_DIR).isDirectory(); } catch { return false; }
224
+ }
225
+
226
+ function getSessionFiles() {
227
+ return walk(CODEX_SESSIONS_DIR);
228
+ }
229
+
230
+ function resolveBin() {
231
+ try { return resolveCodexBin(); } catch { return null; }
232
+ }
233
+
234
+ // Map workbench command names → codex CLI commands
235
+ const COMMAND_MAP = {
236
+ resume: 'resume',
237
+ fork: 'fork',
238
+ delete: 'delete',
239
+ archive: 'archive',
240
+ unarchive: 'unarchive',
241
+ };
242
+
243
+ function runSessionCommand(command, session, args, inherit) {
244
+ const codexCmd = COMMAND_MAP[command];
245
+ if (!codexCmd) throw new Error(`Unknown command for codex backend: ${command}`);
246
+ return runCommand(codexCmd, session, args, inherit);
247
+ }
248
+
249
+ module.exports = {
250
+ id: 'codex',
251
+ label: 'Codex',
252
+ isAvailable,
253
+ getSessionFiles,
254
+ parseSession,
255
+ resolveBin,
256
+ runCommand: runSessionCommand,
257
+ runNew,
258
+ usableCwd,
259
+ // Re-export for backward compat
260
+ shellQuote,
261
+ commandShell,
262
+ resolveCodexBin,
263
+ DEFAULT_CODEX_BIN,
264
+ findOnPath,
265
+ findWithShell,
266
+ isExecutable,
267
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Provider registry – auto-detects available backends, routes operations
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const codex = require('./codex');
8
+ const pi = require('./pi');
9
+
10
+ const ALL_PROVIDERS = [codex, pi];
11
+ const providerMap = new Map(ALL_PROVIDERS.map((p) => [p.id, p]));
12
+
13
+ /**
14
+ * Auto-detect which providers are available (their session directories exist).
15
+ */
16
+ function getAvailableProviders() {
17
+ return ALL_PROVIDERS.filter((p) => p.isAvailable());
18
+ }
19
+
20
+ /**
21
+ * Get a specific provider by id. Throws if not registered.
22
+ */
23
+ function getProvider(id) {
24
+ const p = providerMap.get(id);
25
+ if (!p) throw new Error(`Unknown backend: ${id}. Available: ${[...providerMap.keys()].join(', ')}`);
26
+ return p;
27
+ }
28
+
29
+ /**
30
+ * Get the provider for a session (reads session.backend).
31
+ */
32
+ function providerForSession(session) {
33
+ const backend = session.backend || 'codex';
34
+ return getProvider(backend);
35
+ }
36
+
37
+ /**
38
+ * List all session files across all available providers.
39
+ * Returns [{ file, backend, providerId }].
40
+ */
41
+ function getAllSessionFiles() {
42
+ const files = [];
43
+ for (const provider of getAvailableProviders()) {
44
+ for (const file of provider.getSessionFiles()) {
45
+ files.push({ file, backend: provider.id });
46
+ }
47
+ }
48
+ return files;
49
+ }
50
+
51
+ module.exports = {
52
+ ALL_PROVIDERS,
53
+ getAvailableProviders,
54
+ getProvider,
55
+ providerForSession,
56
+ getAllSessionFiles,
57
+ codex,
58
+ pi,
59
+ };