@bramblex/codex-workbench 0.1.3 → 0.1.4

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/src/config.js ADDED
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const HOME = os.homedir();
7
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(HOME, '.codex');
8
+ const SESSIONS_DIR = process.env.CODEX_SESSIONS_DIR || path.join(CODEX_HOME, 'sessions');
9
+ const META_PATH = process.env.CODEX_WORKBENCH_META || process.env.CSM_META || path.join(CODEX_HOME, 'codex-workbench.json');
10
+ const CONFIG_PATH = process.env.CODEX_WORKBENCH_CONFIG || path.join(CODEX_HOME, 'codex-workbench.config.json');
11
+
12
+ module.exports = {
13
+ HOME,
14
+ CODEX_HOME,
15
+ CONFIG_PATH,
16
+ SESSIONS_DIR,
17
+ META_PATH,
18
+ };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function directoryNameError(name) {
7
+ if (!name) return 'Directory name is required.';
8
+ if (name === '.' || name === '..') return 'Directory name cannot be . or ..';
9
+ if (path.isAbsolute(name)) return 'Directory name must be relative.';
10
+ if (name.includes('/') || name.includes('\\') || name.includes('\0')) return 'Directory name cannot contain path separators.';
11
+ return '';
12
+ }
13
+
14
+ function listDirectories(dir, usableCwd) {
15
+ const cwd = usableCwd ? usableCwd(dir) : path.resolve(dir || process.cwd());
16
+ const entries = fs.readdirSync(cwd, { withFileTypes: true })
17
+ .filter((entry) => entry.isDirectory())
18
+ .map((entry) => ({
19
+ name: entry.name,
20
+ path: path.join(cwd, entry.name),
21
+ }))
22
+ .sort((a, b) => a.name.localeCompare(b.name));
23
+ return { cwd, entries };
24
+ }
25
+
26
+ function createChildDirectory(parent, name) {
27
+ const error = directoryNameError(name);
28
+ if (error) throw new Error(error);
29
+ const target = path.join(parent, name);
30
+ fs.mkdirSync(target);
31
+ return target;
32
+ }
33
+
34
+ module.exports = {
35
+ createChildDirectory,
36
+ directoryNameError,
37
+ listDirectories,
38
+ };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ function shortId(id) {
4
+ return id.slice(0, 13);
5
+ }
6
+
7
+ function localTime(iso) {
8
+ if (!iso) return '';
9
+ return new Date(iso).toLocaleString();
10
+ }
11
+
12
+ function truncate(text, width) {
13
+ if (!text) return '';
14
+ return text.length > width ? text.slice(0, Math.max(0, width - 1)) + '...' : text;
15
+ }
16
+
17
+ module.exports = {
18
+ localTime,
19
+ shortId,
20
+ truncate,
21
+ };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { META_PATH, SESSIONS_DIR } = require('../config');
6
+
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
+ }
34
+
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
+ }
48
+
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;
56
+
57
+ for (const line of lines) {
58
+ let row;
59
+ 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;
77
+ }
78
+ }
79
+
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
+ };
100
+ }
101
+
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
+ }
115
+
116
+ function resolveSession(query, sessions = listSessions()) {
117
+ if (!query) throw new Error('Missing session. Run `codex-workbench list` to find a session id.');
118
+ const matches = sessions.filter((session) => {
119
+ return session.id === query ||
120
+ session.id.startsWith(query) ||
121
+ session.name === query ||
122
+ path.basename(session.file) === query;
123
+ });
124
+ if (matches.length === 1) return matches[0];
125
+ 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);
141
+ }
142
+
143
+ function deleteSessionFile(session) {
144
+ fs.unlinkSync(session.file);
145
+ removeMetadata(session);
146
+ }
147
+
148
+ module.exports = {
149
+ deleteSessionFile,
150
+ listSessions,
151
+ loadMeta,
152
+ parseSession,
153
+ removeMetadata,
154
+ resolveSession,
155
+ updateMetadata,
156
+ };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { CONFIG_PATH } = require('../config');
5
+
6
+ function readWorkbenchConfig(file = CONFIG_PATH) {
7
+ try {
8
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
9
+ return data && typeof data === 'object' ? data : {};
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
14
+
15
+ 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, '-');
19
+ return {
20
+ id,
21
+ label: server.label || server.name || id || `server-${index + 1}`,
22
+ target,
23
+ command: server.command || 'cwb',
24
+ sshArgs: Array.isArray(server.sshArgs) ? server.sshArgs.map(String) : [],
25
+ };
26
+ }
27
+
28
+ function listServers(config = readWorkbenchConfig()) {
29
+ return (Array.isArray(config.servers) ? config.servers : [])
30
+ .map(normalizeServer)
31
+ .filter(Boolean);
32
+ }
33
+
34
+ module.exports = {
35
+ listServers,
36
+ normalizeServer,
37
+ readWorkbenchConfig,
38
+ };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { spawn, spawnSync } = require('child_process');
5
+ const { HOME } = require('../config');
6
+ const { resolveCodexBin } = require('../codex-bin');
7
+
8
+ function usableCwd(dir) {
9
+ const candidates = [dir, process.cwd(), HOME];
10
+ for (const candidate of candidates) {
11
+ if (!candidate || candidate === '(unknown)') continue;
12
+ try {
13
+ if (fs.statSync(candidate).isDirectory()) return candidate;
14
+ } catch {
15
+ // Try the next fallback.
16
+ }
17
+ }
18
+ return HOME;
19
+ }
20
+
21
+ function shellQuote(value) {
22
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
23
+ }
24
+
25
+ function commandShell() {
26
+ const shell = process.env.SHELL || '/bin/sh';
27
+ try {
28
+ fs.accessSync(shell, fs.constants.X_OK);
29
+ return shell;
30
+ } catch {
31
+ return '/bin/sh';
32
+ }
33
+ }
34
+
35
+ function runCodexArgv(argv, cwd, inherit = false) {
36
+ const shellCommand = `exec ${argv.map(shellQuote).join(' ')}`;
37
+ const shell = commandShell();
38
+ if (inherit) {
39
+ const child = spawn(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
40
+ child.on('error', (err) => {
41
+ console.error(`error: failed to start codex: ${err.message}`);
42
+ process.exit(1);
43
+ });
44
+ child.on('exit', (code, signal) => {
45
+ if (signal) process.kill(process.pid, signal);
46
+ process.exit(code || 0);
47
+ });
48
+ return undefined;
49
+ }
50
+ const result = spawnSync(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
51
+ if (result.error) throw new Error(`failed to start codex: ${result.error.message}`);
52
+ const status = typeof result.status === 'number' ? result.status : 1;
53
+ process.exitCode = status;
54
+ return status;
55
+ }
56
+
57
+ function runCodexCommand(command, session, args = [], inherit = false) {
58
+ const executable = resolveCodexBin();
59
+ const argv = [executable, command, session.id, ...args];
60
+ return runCodexArgv(argv, usableCwd(session.cwd), inherit);
61
+ }
62
+
63
+ function runNewCodexSession(cwd, args = [], inherit = false) {
64
+ const executable = resolveCodexBin();
65
+ const argv = [executable, ...args];
66
+ return runCodexArgv(argv, usableCwd(cwd), inherit);
67
+ }
68
+
69
+ module.exports = {
70
+ commandShell,
71
+ runCodexCommand,
72
+ runNewCodexSession,
73
+ shellQuote,
74
+ usableCwd,
75
+ };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { createChildDirectory, listDirectories } = require('../model/directories');
5
+ const { listSessions, updateMetadata } = require('../model/session-store');
6
+ const { listServers } = require('../model/workbench-config');
7
+ const { runCodexCommand, runNewCodexSession, usableCwd } = require('./codex-runner');
8
+ const { runRemoteCwb, runRemoteCwbJson } = require('./ssh-runner');
9
+
10
+ const LOCAL_SOURCE = {
11
+ id: 'local',
12
+ label: 'Local',
13
+ type: 'local',
14
+ remote: false,
15
+ };
16
+
17
+ function sourceForServer(server) {
18
+ return {
19
+ ...server,
20
+ type: 'ssh',
21
+ remote: true,
22
+ };
23
+ }
24
+
25
+ function sourceKey(sourceId, id) {
26
+ return `${sourceId}:${id}`;
27
+ }
28
+
29
+ function attachSource(session, source) {
30
+ return {
31
+ ...session,
32
+ sourceId: source.id,
33
+ sourceLabel: source.label,
34
+ sourceType: source.type,
35
+ sourceRemote: source.remote,
36
+ sourceKey: sourceKey(source.id, session.id),
37
+ };
38
+ }
39
+
40
+ function configuredSources() {
41
+ return [LOCAL_SOURCE, ...listServers().map(sourceForServer)];
42
+ }
43
+
44
+ function loadWorkbenchSessions() {
45
+ const sources = configuredSources();
46
+ const sessions = listSessions().map((session) => attachSource(session, LOCAL_SOURCE));
47
+ const errors = [];
48
+
49
+ for (const source of sources.filter((candidate) => candidate.remote)) {
50
+ try {
51
+ const remoteSessions = runRemoteCwbJson(source, ['list', '--json']);
52
+ if (!Array.isArray(remoteSessions)) throw new Error('remote list did not return an array');
53
+ sessions.push(...remoteSessions.map((session) => attachSource(session, source)));
54
+ } catch (err) {
55
+ errors.push({ source, error: err.message });
56
+ }
57
+ }
58
+
59
+ sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
60
+ return { errors, sessions, sources };
61
+ }
62
+
63
+ function sourceById(sources, sourceId) {
64
+ return sources.find((source) => source.id === sourceId) || LOCAL_SOURCE;
65
+ }
66
+
67
+ function configuredSourceOrThrow(sourceId) {
68
+ const source = configuredSources().find((candidate) => candidate.id === sourceId);
69
+ if (!source) throw new Error(`Remote source is not configured: ${sourceId}`);
70
+ return source;
71
+ }
72
+
73
+ function resultStatus(result) {
74
+ return typeof result.status === 'number' ? result.status : 1;
75
+ }
76
+
77
+ function runSourceSessionCommand(session, command, args = []) {
78
+ if (!session.sourceRemote) return runCodexCommand(command, session, args);
79
+ const source = configuredSourceOrThrow(session.sourceId);
80
+ const tty = command === 'resume' || command === 'fork';
81
+ const result = runRemoteCwb(source, [command, session.id, ...args], { tty });
82
+ if (result.error) throw result.error;
83
+ const status = resultStatus(result);
84
+ process.exitCode = status;
85
+ return status;
86
+ }
87
+
88
+ function runSourceNewSession(source, cwd, args = []) {
89
+ if (!source || !source.remote) return runNewCodexSession(cwd, args);
90
+ const result = runRemoteCwb(source, ['new', '--cwd', cwd, ...args], { tty: true });
91
+ if (result.error) throw result.error;
92
+ const status = resultStatus(result);
93
+ process.exitCode = status;
94
+ return status;
95
+ }
96
+
97
+ function updateSourceMetadata(session, patch) {
98
+ if (!session.sourceRemote) {
99
+ updateMetadata(session, patch);
100
+ return 0;
101
+ }
102
+ const source = configuredSourceOrThrow(session.sourceId);
103
+ let result = null;
104
+ if (Object.prototype.hasOwnProperty.call(patch, 'name')) {
105
+ result = runRemoteCwb(source, ['rename', session.id, patch.name || '']);
106
+ } else if (Object.prototype.hasOwnProperty.call(patch, 'note')) {
107
+ result = runRemoteCwb(source, ['note', session.id, patch.note || '']);
108
+ } else if (Object.prototype.hasOwnProperty.call(patch, 'hidden')) {
109
+ result = runRemoteCwb(source, [patch.hidden ? 'hide' : 'unhide', session.id]);
110
+ }
111
+ if (!result) return 0;
112
+ if (result.error) throw result.error;
113
+ const status = resultStatus(result);
114
+ process.exitCode = status;
115
+ return status;
116
+ }
117
+
118
+ function listSourceDirectories(source, dir) {
119
+ if (!source || !source.remote) return listDirectories(dir, usableCwd);
120
+ const payload = runRemoteCwbJson(source, ['dirs', '--cwd', dir, '--json']);
121
+ return {
122
+ cwd: payload.cwd || dir || '.',
123
+ entries: (payload.entries || []).map((entry) => ({
124
+ name: entry.name || path.basename(entry.path),
125
+ path: entry.path,
126
+ })),
127
+ };
128
+ }
129
+
130
+ function createSourceDirectory(source, parent, name) {
131
+ if (!source || !source.remote) return createChildDirectory(parent, name);
132
+ const payload = runRemoteCwbJson(source, ['mkdir', '--cwd', parent, '--json', name]);
133
+ return payload.path;
134
+ }
135
+
136
+ module.exports = {
137
+ LOCAL_SOURCE,
138
+ attachSource,
139
+ configuredSources,
140
+ createSourceDirectory,
141
+ listSourceDirectories,
142
+ loadWorkbenchSessions,
143
+ runSourceNewSession,
144
+ runSourceSessionCommand,
145
+ sourceById,
146
+ sourceKey,
147
+ updateSourceMetadata,
148
+ };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const { shellQuote } = require('./codex-runner');
5
+
6
+ function sshBaseArgs(server, opts = {}) {
7
+ const args = [];
8
+ if (opts.tty) args.push('-t');
9
+ args.push(...(server.sshArgs || []), server.target);
10
+ return args;
11
+ }
12
+
13
+ function remoteCwbCommand(server, argv) {
14
+ const command = server.command || 'cwb';
15
+ return [command, ...argv].map(shellQuote).join(' ');
16
+ }
17
+
18
+ function runRemoteCwb(server, argv, opts = {}) {
19
+ const command = remoteCwbCommand(server, argv);
20
+ const args = [...sshBaseArgs(server, { tty: opts.tty }), command];
21
+ return spawnSync('ssh', args, {
22
+ encoding: opts.encoding,
23
+ env: process.env,
24
+ stdio: opts.stdio || (opts.encoding ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
25
+ });
26
+ }
27
+
28
+ function runRemoteCwbJson(server, argv) {
29
+ const result = runRemoteCwb(server, argv, { encoding: 'utf8' });
30
+ if (result.error) throw result.error;
31
+ if (result.status !== 0) {
32
+ const stderr = (result.stderr || '').trim();
33
+ throw new Error(stderr || `ssh exited with code ${result.status}`);
34
+ }
35
+ return JSON.parse(result.stdout || 'null');
36
+ }
37
+
38
+ module.exports = {
39
+ remoteCwbCommand,
40
+ runRemoteCwb,
41
+ runRemoteCwbJson,
42
+ sshBaseArgs,
43
+ };