@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.
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // pi 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, PI_CODING_AGENT_DIR } = require('../config');
11
+ const { updateMetadata } = require('../model/metadata');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Paths
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const PI_SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR ||
18
+ path.join(PI_CODING_AGENT_DIR, 'sessions');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function shellQuote(value) {
25
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
26
+ }
27
+
28
+ function isExecutable(file) {
29
+ try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
30
+ }
31
+
32
+ function findOnPath(command, pathValue) {
33
+ for (const dir of (pathValue || process.env.PATH || '').split(path.delimiter)) {
34
+ if (!dir) continue;
35
+ const candidate = path.join(dir, command);
36
+ if (isExecutable(candidate)) return candidate;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function usableCwd(dir) {
42
+ for (const candidate of [dir, process.cwd(), HOME]) {
43
+ if (!candidate || candidate === '(unknown)') continue;
44
+ try { if (fs.statSync(candidate).isDirectory()) return candidate; } catch { /* skip */ }
45
+ }
46
+ return HOME;
47
+ }
48
+
49
+ function commandShell() {
50
+ const shell = process.env.SHELL || '/bin/sh';
51
+ try { fs.accessSync(shell, fs.constants.X_OK); return shell; } catch { return '/bin/sh'; }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Binary discovery
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function resolvePiBin() {
59
+ const env = process.env;
60
+
61
+ // PI_BIN env var takes priority
62
+ if (env.PI_BIN) {
63
+ if (isExecutable(env.PI_BIN)) return env.PI_BIN;
64
+ throw new Error(`PI_BIN is not executable: ${env.PI_BIN}`);
65
+ }
66
+
67
+ // Check PATH
68
+ const fromPath = findOnPath('pi', env.PATH);
69
+ if (fromPath) return fromPath;
70
+
71
+ // npm global bin (common locations)
72
+ const npmPrefix = (() => {
73
+ try {
74
+ const result = spawnSync('npm', ['prefix', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
75
+ return (result.stdout || '').trim();
76
+ } catch { return ''; }
77
+ })();
78
+
79
+ if (npmPrefix) {
80
+ const candidate = path.join(npmPrefix, 'bin', 'pi');
81
+ if (isExecutable(candidate)) return candidate;
82
+ }
83
+
84
+ throw new Error('Could not find the pi executable. Install with: npm install -g @earendil-works/pi-coding-agent');
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Session listing
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function walk(dir, out) {
92
+ out = out || [];
93
+ let entries;
94
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
95
+ for (const entry of entries) {
96
+ const full = path.join(dir, entry.name);
97
+ if (entry.isDirectory()) walk(full, out);
98
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Session parsing – pi v3 JSONL format
105
+ //
106
+ // Header: {"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path"}
107
+ // Message: {"type":"message","id":"...","parentId":"...","message":{"role":"user|assistant|toolResult","content":[...]}}
108
+ // Model: {"type":"model_change", ...}
109
+ // Thinking:{"type":"thinking_level_change", ...}
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function piTextFromContent(content) {
113
+ if (typeof content === 'string') return content;
114
+ if (!Array.isArray(content)) return '';
115
+ return content
116
+ .filter((item) => item && item.type === 'text')
117
+ .map((item) => item.text || '')
118
+ .join(' ')
119
+ .replace(/\s+/g, ' ')
120
+ .trim();
121
+ }
122
+
123
+ function parseSession(file) {
124
+ const stat = fs.statSync(file);
125
+ const raw = fs.readFileSync(file, 'utf8').trim();
126
+ if (!raw) {
127
+ return emptySession(file, stat, path.basename(file, '.jsonl'));
128
+ }
129
+
130
+ const lines = raw.split(/\n/);
131
+ let header = {};
132
+ const messages = [];
133
+ let turns = 0;
134
+ let provider = '';
135
+ let cliVersion = '';
136
+
137
+ for (const line of lines) {
138
+ let row;
139
+ try { row = JSON.parse(line); } catch { continue; }
140
+
141
+ if (row.type === 'session') {
142
+ header = row;
143
+ continue;
144
+ }
145
+
146
+ if (row.type === 'model_change') {
147
+ provider = row.provider || '';
148
+ continue;
149
+ }
150
+
151
+ if (row.type === 'message' && row.message) {
152
+ const msg = row.message;
153
+ if (msg.role === 'toolResult') continue; // skip tool results for display purposes
154
+
155
+ const text = piTextFromContent(msg.content);
156
+ // Skip empty messages (e.g. tool calls with no text)
157
+ if (!text && msg.role !== 'user') continue;
158
+
159
+ messages.push({
160
+ role: msg.role,
161
+ text: text || '',
162
+ });
163
+
164
+ if (msg.role === 'user') turns += 1;
165
+
166
+ // Extract provider from first assistant message
167
+ if (!provider && msg.role === 'assistant' && msg.provider) {
168
+ provider = msg.provider;
169
+ }
170
+ }
171
+ }
172
+
173
+ const id = header.id || extractIdFromFilename(file);
174
+
175
+ return {
176
+ id,
177
+ file,
178
+ cwd: header.cwd || '(unknown)',
179
+ startedAt: header.timestamp || stat.birthtime?.toISOString() || null,
180
+ updatedAt: stat.mtime.toISOString(),
181
+ cliVersion,
182
+ provider,
183
+ turns,
184
+ first: firstUserText(messages),
185
+ last: lastUserText(messages),
186
+ lastAssistant: lastAssistantText(messages),
187
+ messages,
188
+ backend: 'pi',
189
+ };
190
+ }
191
+
192
+ function extractIdFromFilename(file) {
193
+ const base = path.basename(file, '.jsonl');
194
+ // pi filenames: <timestamp>_<uuid>.jsonl
195
+ const parts = base.split('_');
196
+ if (parts.length >= 2) return parts[parts.length - 1];
197
+ return base;
198
+ }
199
+
200
+ function emptySession(file, stat, fallbackId) {
201
+ return {
202
+ id: extractIdFromFilename(file) || fallbackId,
203
+ file,
204
+ cwd: '(unknown)',
205
+ startedAt: stat.birthtime?.toISOString() || null,
206
+ updatedAt: stat.mtime.toISOString(),
207
+ cliVersion: '',
208
+ provider: '',
209
+ turns: 0,
210
+ first: '',
211
+ last: '',
212
+ lastAssistant: '',
213
+ messages: [],
214
+ backend: 'pi',
215
+ };
216
+ }
217
+
218
+ function firstUserText(messages) {
219
+ const m = messages.find((msg) => msg.role === 'user');
220
+ return m ? m.text : '';
221
+ }
222
+
223
+ function lastUserText(messages) {
224
+ for (let i = messages.length - 1; i >= 0; i--) {
225
+ if (messages[i].role === 'user') return messages[i].text;
226
+ }
227
+ return '';
228
+ }
229
+
230
+ function lastAssistantText(messages) {
231
+ for (let i = messages.length - 1; i >= 0; i--) {
232
+ if (messages[i].role === 'assistant') return messages[i].text;
233
+ }
234
+ return '';
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // CLI execution
239
+ // ---------------------------------------------------------------------------
240
+
241
+ function runArgv(argv, cwd, inherit) {
242
+ const shellCommand = `exec ${argv.map(shellQuote).join(' ')}`;
243
+ const shell = commandShell();
244
+ if (inherit) {
245
+ const child = spawn(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
246
+ child.on('error', (err) => { console.error(`error: failed to start pi: ${err.message}`); process.exit(1); });
247
+ child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); process.exit(code || 0); });
248
+ return undefined;
249
+ }
250
+ const result = spawnSync(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
251
+ if (result.error) throw new Error(`failed to start pi: ${result.error.message}`);
252
+ const status = typeof result.status === 'number' ? result.status : 1;
253
+ process.exitCode = status;
254
+ return status;
255
+ }
256
+
257
+ // pi CLI commands mapping
258
+ function runSessionCommand(command, session, args, inherit) {
259
+ const executable = resolvePiBin();
260
+ const cwd = usableCwd(session.cwd);
261
+
262
+ switch (command) {
263
+ case 'resume': {
264
+ // pi --session <file> [args...]
265
+ let argv = [executable, '--session', session.file];
266
+ if (args && args.length) argv.push('-p', args.join(' '));
267
+ return runArgv(argv, cwd, inherit);
268
+ }
269
+ case 'fork': {
270
+ // pi --fork <file>
271
+ const argv = [executable, '--fork', session.file];
272
+ return runArgv(argv, cwd, inherit);
273
+ }
274
+ case 'delete': {
275
+ // pi has no delete CLI – just remove the file
276
+ // A force flag is handled by session-sources calling deleteSessionFile
277
+ return -1; // signal that file-based deletion should be used
278
+ }
279
+ case 'archive':
280
+ case 'unarchive': {
281
+ updateMetadata(session, { archived: command === 'archive' });
282
+ return 0;
283
+ }
284
+ default:
285
+ throw new Error(`Unknown command for pi backend: ${command}`);
286
+ }
287
+ }
288
+
289
+ function runNew(cwd, args, inherit) {
290
+ const executable = resolvePiBin();
291
+ let argv = [executable];
292
+ if (args && args.length) argv.push('-p', args.join(' '));
293
+ return runArgv(argv, usableCwd(cwd), inherit);
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Provider interface
298
+ // ---------------------------------------------------------------------------
299
+
300
+ function isAvailable() {
301
+ try { return fs.statSync(PI_SESSIONS_DIR).isDirectory(); } catch { return false; }
302
+ }
303
+
304
+ function getSessionFiles() {
305
+ return walk(PI_SESSIONS_DIR);
306
+ }
307
+
308
+ function resolveBin() {
309
+ try { return resolvePiBin(); } catch { return null; }
310
+ }
311
+
312
+ module.exports = {
313
+ id: 'pi',
314
+ label: 'pi',
315
+ isAvailable,
316
+ getSessionFiles,
317
+ parseSession,
318
+ resolveBin,
319
+ runCommand: runSessionCommand,
320
+ runNew,
321
+ usableCwd,
322
+ // Re-exports
323
+ shellQuote,
324
+ commandShell,
325
+ resolvePiBin,
326
+ };
@@ -1,69 +1,34 @@
1
1
  'use strict';
2
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);
3
+ // ---------------------------------------------------------------------------
4
+ // Backward-compatible re-exports delegates to the provider layer.
5
+ // New code should import directly from src/providers/ and use
6
+ // providerForSession() to route by session.backend.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const codex = require('../providers/codex');
10
+ const { providerForSession } = require('../providers');
11
+
12
+ // Re-export low-level helpers
13
+ const shellQuote = codex.shellQuote;
14
+ const commandShell = codex.commandShell;
15
+ const usableCwd = codex.usableCwd;
16
+
17
+ /**
18
+ * Run a CLI command against a session, routing to the correct provider backend.
19
+ */
20
+ function runCodexCommand(command, session, args, inherit) {
21
+ const provider = providerForSession(session);
22
+ return provider.runCommand(command, session, args, inherit);
61
23
  }
62
24
 
63
- function runNewCodexSession(cwd, args = [], inherit = false) {
64
- const executable = resolveCodexBin();
65
- const argv = [executable, ...args];
66
- return runCodexArgv(argv, usableCwd(cwd), inherit);
25
+ /**
26
+ * Start a new session, routing to the correct provider backend.
27
+ * Defaults to codex if no backend is specified.
28
+ */
29
+ function runNewCodexSession(cwd, args, inherit, backend) {
30
+ const provider = backend ? require('../providers').getProvider(backend) : codex;
31
+ return provider.runNew(cwd, args, inherit);
67
32
  }
68
33
 
69
34
  module.exports = {
@@ -6,6 +6,7 @@ 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
8
  const { runRemoteCwb, runRemoteCwbJson, runRemoteCwbJsonAsync } = require('./ssh-runner');
9
+ const { getAvailableProviders } = require('../providers');
9
10
 
10
11
  const LOCAL_SOURCE = {
11
12
  id: 'local',
@@ -46,7 +47,8 @@ function sortSessions(sessions) {
46
47
  return sessions;
47
48
  }
48
49
 
49
- function loadLocalWorkbenchSessions(sources = configuredSources()) {
50
+ function loadLocalWorkbenchSessions(sources) {
51
+ if (!sources) sources = configuredSources();
50
52
  const sessions = listSessions().map((session) => attachSource(session, LOCAL_SOURCE));
51
53
  return { errors: [], sessions, sources };
52
54
  }
@@ -91,8 +93,48 @@ function resultStatus(result) {
91
93
  return typeof result.status === 'number' ? result.status : 1;
92
94
  }
93
95
 
94
- function runSourceSessionCommand(session, command, args = []) {
95
- if (!session.sourceRemote) return runCodexCommand(command, session, args);
96
+ function defaultBackend() {
97
+ const available = getAvailableProviders();
98
+ if (available.length === 0) return 'codex'; // fallback
99
+ // Prefer codex for backward compat; use the only one if just one is available
100
+ const codexAvail = available.find((p) => p.id === 'codex');
101
+ return codexAvail ? 'codex' : available[0].id;
102
+ }
103
+
104
+ function providerSummary(provider) {
105
+ return {
106
+ id: provider.id,
107
+ label: provider.label || provider.id,
108
+ };
109
+ }
110
+
111
+ function listLocalBackends() {
112
+ return getAvailableProviders().map(providerSummary);
113
+ }
114
+
115
+ function listSourceBackends(source) {
116
+ if (!source || !source.remote) return listLocalBackends();
117
+ const payload = runRemoteCwbJson(source, ['backends', '--json']);
118
+ if (!Array.isArray(payload)) throw new Error('remote backends did not return an array');
119
+ return payload
120
+ .filter((backend) => backend && backend.id)
121
+ .map((backend) => ({
122
+ id: String(backend.id),
123
+ label: backend.label ? String(backend.label) : String(backend.id),
124
+ }));
125
+ }
126
+
127
+ function runSourceSessionCommand(session, command, args) {
128
+ if (!session.sourceRemote) {
129
+ const status = runCodexCommand(command, session, args);
130
+ // If the provider returns -1 (e.g. pi delete = file-based), fall through to file deletion
131
+ if (status === -1) {
132
+ const { deleteSessionFile } = require('../model/session-store');
133
+ deleteSessionFile(session);
134
+ return 0;
135
+ }
136
+ return status;
137
+ }
96
138
  const source = configuredSourceOrThrow(session.sourceId);
97
139
  const tty = command === 'resume' || command === 'fork';
98
140
  const result = runRemoteCwb(source, [command, session.id, ...args], { tty });
@@ -102,9 +144,10 @@ function runSourceSessionCommand(session, command, args = []) {
102
144
  return status;
103
145
  }
104
146
 
105
- function runSourceNewSession(source, cwd, args = []) {
106
- if (!source || !source.remote) return runNewCodexSession(cwd, args);
107
- const result = runRemoteCwb(source, ['new', '--cwd', cwd, ...args], { tty: true });
147
+ function runSourceNewSession(source, cwd, args, backend) {
148
+ if (!source || !source.remote) return runNewCodexSession(cwd, args, true, backend || defaultBackend());
149
+ const backendArgs = backend ? ['--backend', backend] : [];
150
+ const result = runRemoteCwb(source, ['new', '--cwd', cwd, ...backendArgs, ...args], { tty: true });
108
151
  if (result.error) throw result.error;
109
152
  const status = resultStatus(result);
110
153
  process.exitCode = status;
@@ -122,8 +165,6 @@ function updateSourceMetadata(session, patch) {
122
165
  result = runRemoteCwb(source, ['rename', session.id, patch.name || '']);
123
166
  } else if (Object.prototype.hasOwnProperty.call(patch, 'note')) {
124
167
  result = runRemoteCwb(source, ['note', session.id, patch.note || '']);
125
- } else if (Object.prototype.hasOwnProperty.call(patch, 'hidden')) {
126
- result = runRemoteCwb(source, [patch.hidden ? 'hide' : 'unhide', session.id]);
127
168
  }
128
169
  if (!result) return 0;
129
170
  if (result.error) throw result.error;
@@ -156,6 +197,8 @@ module.exports = {
156
197
  configuredSources,
157
198
  createSourceDirectory,
158
199
  listSourceDirectories,
200
+ listLocalBackends,
201
+ listSourceBackends,
159
202
  loadLocalWorkbenchSessions,
160
203
  loadRemoteSourceSessions,
161
204
  loadWorkbenchSessions,
@@ -164,4 +207,5 @@ module.exports = {
164
207
  sourceById,
165
208
  sourceKey,
166
209
  updateSourceMetadata,
210
+ defaultBackend,
167
211
  };