@bramblex/codex-workbench 0.1.18 → 0.1.20

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
@@ -18,7 +18,7 @@ codex-workbench is an **interactive terminal UI** for coding-agent sessions. Ins
18
18
 
19
19
  It also connects to **remote machines over SSH**, so you can manage sessions across all your servers from a single pane of glass.
20
20
 
21
- Built-in backends currently include [Codex](https://github.com/openai/codex) and pi. The backend layer is intentionally provider-based so additional agents can be added without changing the TUI workflow.
21
+ Built-in backends currently include [Codex](https://github.com/openai/codex), Claude Code, pi, and opencode. The backend layer is intentionally provider-based so additional agents can be added without changing the TUI workflow.
22
22
 
23
23
  A handful of CLI subcommands are available for scripting, but the TUI is the product.
24
24
 
@@ -131,12 +131,14 @@ Remote backends are supported as long as the remote `cwb` can read them.
131
131
 
132
132
  ## Backends
133
133
 
134
- codex-workbench auto-detects installed backends by checking their session directories.
134
+ codex-workbench auto-detects installed backends by checking each backend's session storage.
135
135
 
136
136
  | Backend | Sessions | Binary override | Notes |
137
137
  |---------|----------|-----------------|-------|
138
138
  | `codex` | `$CODEX_SESSIONS_DIR` or `~/.codex/sessions` | `CODEX_BIN` | Uses the Codex CLI for new, resume, fork, archive, unarchive, and delete. |
139
+ | `claude` | `$CLAUDE_PROJECTS_DIR` or `~/.claude/projects` | `CLAUDE_BIN` | Uses the Claude Code CLI for new, resume, and fork. Archive/unarchive use workbench metadata; delete removes the session file. |
139
140
  | `pi` | `$PI_CODING_AGENT_SESSION_DIR` or `$PI_CODING_AGENT_DIR/sessions` | `PI_BIN` | Uses the pi CLI for new, resume, and fork. Archive/unarchive use workbench metadata; delete removes the session file. |
141
+ | `opencode` | `$OPENCODE_DB`, `$OPENCODE_DATA_DIR/opencode.db`, or `~/.local/share/opencode/opencode.db` | `OPENCODE_BIN` | Uses the opencode CLI and database for list, new, resume, fork, archive, unarchive, and delete. |
140
142
 
141
143
  Session metadata such as custom names, notes, and archive state is stored in workbench's own metadata file, not inside backend session files.
142
144
 
@@ -164,7 +166,9 @@ cwb fork <session>
164
166
  cwb delete <session> --force
165
167
 
166
168
  cwb new --cwd ~/projects/foo --backend codex "Summarize this repo"
169
+ cwb new --cwd ~/projects/foo --backend claude "Summarize this repo"
167
170
  cwb new --cwd ~/projects/foo --backend pi "Summarize this repo"
171
+ cwb new --cwd ~/projects/foo --backend opencode "Summarize this repo"
168
172
  cwb resume <session> "what was the conclusion about the rate limiter?"
169
173
 
170
174
  cwb dirs --cwd ~/projects
@@ -189,12 +193,18 @@ When you run `new` or `resume`, the selected backend takes over the terminal. Wh
189
193
  | `CWB_CONFIG` | `$CWB_HOME/config.json` | SSH remote sources config |
190
194
  | `CODEX_HOME` | `~/.codex` | Codex data directory |
191
195
  | `CODEX_SESSIONS_DIR` | `$CODEX_HOME/sessions` | Session JSONL files |
196
+ | `CLAUDE_HOME` | `~/.claude` | Claude Code data directory |
197
+ | `CLAUDE_PROJECTS_DIR` | `$CLAUDE_HOME/projects` | Claude Code project session JSONL files |
192
198
  | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi coding agent data directory |
193
199
  | `PI_CODING_AGENT_SESSION_DIR` | `$PI_CODING_AGENT_DIR/sessions` | pi session JSONL files |
200
+ | `OPENCODE_DATA_DIR` | `~/.local/share/opencode` | opencode data directory |
201
+ | `OPENCODE_DB` | `$OPENCODE_DATA_DIR/opencode.db` | opencode SQLite database |
194
202
  | `CODEX_WORKBENCH_META` | unset | Legacy override for `CWB_META` |
195
203
  | `CODEX_WORKBENCH_CONFIG` | unset | Legacy override for `CWB_CONFIG` |
196
204
  | `CODEX_BIN` | auto-detected | Force a specific Codex executable |
205
+ | `CLAUDE_BIN` | auto-detected | Force a specific Claude Code executable |
197
206
  | `PI_BIN` | auto-detected | Force a specific pi executable |
207
+ | `OPENCODE_BIN` | auto-detected | Force a specific opencode executable |
198
208
 
199
209
  By default, codex-workbench discovers the `codex` binary through your login shell's `PATH`. Set `CODEX_BIN` to override.
200
210
 
@@ -218,10 +228,18 @@ Run `codex-workbench doctor` to see where codex-workbench is looking. Common fix
218
228
 
219
229
  Make sure you've run Codex at least once. Sessions are stored as `.jsonl` files under `$CODEX_SESSIONS_DIR`. Run `ls ~/.codex/sessions/` to verify.
220
230
 
231
+ ### No Claude Code sessions appear
232
+
233
+ Make sure you've run Claude Code at least once. Sessions are stored as `.jsonl` files under `$CLAUDE_PROJECTS_DIR`. Run `find ~/.claude/projects -name '*.jsonl'` to verify.
234
+
221
235
  ### No pi sessions appear
222
236
 
223
237
  Make sure you've run the pi coding agent at least once. Sessions are stored as `.jsonl` files under `$PI_CODING_AGENT_SESSION_DIR` or `$PI_CODING_AGENT_DIR/sessions`. Run `ls ~/.pi/agent/sessions/` to verify.
224
238
 
239
+ ### No opencode sessions found
240
+
241
+ Make sure you've run opencode at least once. Sessions are stored in the SQLite database at `$OPENCODE_DB` or `$OPENCODE_DATA_DIR/opencode.db`. Run `opencode session list --format json` or `opencode db path` to verify.
242
+
225
243
  ### A backend is missing from doctor
226
244
 
227
245
  Backends appear only when their session directory exists. For a new backend integration, add a provider under `src/providers/` with session discovery, parsing, binary discovery, and command routing.
@@ -284,7 +302,9 @@ src/
284
302
  workbench-config.js # SSH remote source config loader
285
303
  providers/
286
304
  codex.js # Codex provider
305
+ claude.js # Claude Code provider
287
306
  pi.js # pi provider
307
+ opencode.js # opencode provider
288
308
  index.js # provider registry
289
309
  services/
290
310
  codex-runner.js # backward-compatible provider runner wrapper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bramblex/codex-workbench",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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
@@ -31,9 +31,18 @@ Environment:
31
31
  CWB_CONFIG default: $CWB_HOME/config.json
32
32
  CODEX_HOME default: ~/.codex
33
33
  CODEX_SESSIONS_DIR default: $CODEX_HOME/sessions
34
+ CLAUDE_HOME default: ~/.claude
35
+ CLAUDE_PROJECTS_DIR default: $CLAUDE_HOME/projects
36
+ PI_CODING_AGENT_DIR default: ~/.pi/agent
37
+ PI_CODING_AGENT_SESSION_DIR default: $PI_CODING_AGENT_DIR/sessions
38
+ OPENCODE_DATA_DIR default: ~/.local/share/opencode
39
+ OPENCODE_DB default: $OPENCODE_DATA_DIR/opencode.db
34
40
  CODEX_WORKBENCH_META legacy override for CWB_META
35
41
  CODEX_WORKBENCH_CONFIG legacy override for CWB_CONFIG
36
42
  CODEX_BIN default: codex from shell PATH
43
+ CLAUDE_BIN default: claude from shell PATH
44
+ PI_BIN default: pi from shell PATH
45
+ OPENCODE_BIN default: opencode from shell PATH
37
46
  `);
38
47
  }
39
48
 
@@ -106,8 +115,13 @@ function printDoctor() {
106
115
  try { provider.resolveBin(); } catch (err) { console.log(` error: ${err.message}`); }
107
116
  }
108
117
  try {
109
- const files = provider.getSessionFiles();
110
- console.log(` sessions: ${files.length} file${files.length === 1 ? '' : 's'}`);
118
+ if (provider.listSessions) {
119
+ const sessions = provider.listSessions();
120
+ console.log(` sessions: ${sessions.length}`);
121
+ } else {
122
+ const files = provider.getSessionFiles();
123
+ console.log(` sessions: ${files.length} file${files.length === 1 ? '' : 's'}`);
124
+ }
111
125
  } catch (err) {
112
126
  console.log(` sessions: error - ${err.message}`);
113
127
  }
@@ -18,10 +18,10 @@ function listSessions() {
18
18
  const fileEntries = getAllSessionFiles();
19
19
 
20
20
  const sessions = [];
21
- for (const { file, backend } of fileEntries) {
21
+ for (const { file, backend, session: listedSession } of fileEntries) {
22
22
  try {
23
23
  const provider = getProvider(backend);
24
- const session = provider.parseSession(file);
24
+ const session = listedSession || provider.parseSession(file);
25
25
  // Merge workbench metadata (name, note, archived)
26
26
  const custom = meta.sessions[session.id] || {};
27
27
  sessions.push({ ...session, ...custom });
@@ -45,7 +45,7 @@ function resolveSession(query, sessions) {
45
45
  return session.id === query ||
46
46
  session.id.startsWith(query) ||
47
47
  session.name === query ||
48
- path.basename(session.file) === query;
48
+ (session.file && path.basename(session.file) === query);
49
49
  });
50
50
  if (matches.length === 1) return matches[0];
51
51
  if (matches.length === 0) throw new Error(`No session matched: ${query}`);
@@ -55,6 +55,7 @@ function resolveSession(query, sessions) {
55
55
  }
56
56
 
57
57
  function deleteSessionFile(session) {
58
+ if (!session.file) throw new Error(`Session does not have a standalone file: ${session.id}`);
58
59
  fs.unlinkSync(session.file);
59
60
  removeMetadata(session);
60
61
  }
@@ -0,0 +1,283 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Claude Code provider – session parsing, binary discovery, and CLI operations
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawn, spawnSync } = require('child_process');
11
+ const { removeMetadata, updateMetadata } = require('../model/metadata');
12
+
13
+ const HOME = os.homedir();
14
+ const CLAUDE_HOME = process.env.CLAUDE_HOME || path.join(HOME, '.claude');
15
+ const CLAUDE_PROJECTS_DIR = process.env.CLAUDE_PROJECTS_DIR || path.join(CLAUDE_HOME, 'projects');
16
+
17
+ function shellQuote(value) {
18
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
19
+ }
20
+
21
+ function isExecutable(file) {
22
+ try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
23
+ }
24
+
25
+ function findOnPath(command, pathValue) {
26
+ for (const dir of (pathValue || process.env.PATH || '').split(path.delimiter)) {
27
+ if (!dir) continue;
28
+ const candidate = path.join(dir, command);
29
+ if (isExecutable(candidate)) return candidate;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function executableFromOutput(output) {
35
+ for (const line of String(output || '').split(/\r?\n/)) {
36
+ const candidate = line.trim();
37
+ if (candidate && path.isAbsolute(candidate) && isExecutable(candidate)) return candidate;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function runShellLookup(shell, shellArgs, command, env) {
43
+ const result = spawnSync(shell, [...shellArgs, `command -v ${shellQuote(command)}`], {
44
+ encoding: 'utf8',
45
+ env,
46
+ stdio: ['ignore', 'pipe', 'pipe'],
47
+ });
48
+ if (result.error || result.status !== 0) return null;
49
+ return executableFromOutput(result.stdout);
50
+ }
51
+
52
+ function findWithShell(command, env) {
53
+ const shell = (env || process.env).SHELL || '/bin/sh';
54
+ if (!isExecutable(shell)) return null;
55
+ return runShellLookup(shell, ['-lc'], command, env) ||
56
+ runShellLookup(shell, ['-ic'], command, env);
57
+ }
58
+
59
+ function resolveClaudeBin() {
60
+ const env = process.env;
61
+ if (env.CLAUDE_BIN) {
62
+ if (isExecutable(env.CLAUDE_BIN)) return env.CLAUDE_BIN;
63
+ throw new Error(`CLAUDE_BIN is not executable: ${env.CLAUDE_BIN}`);
64
+ }
65
+ const fromShell = findWithShell('claude', env);
66
+ if (fromShell) return fromShell;
67
+ const fromPath = findOnPath('claude', env.PATH);
68
+ if (fromPath) return fromPath;
69
+ throw new Error('Could not find the claude executable. Set CLAUDE_BIN or add claude to PATH.');
70
+ }
71
+
72
+ function commandShell() {
73
+ const shell = process.env.SHELL || '/bin/sh';
74
+ try { fs.accessSync(shell, fs.constants.X_OK); return shell; } catch { return '/bin/sh'; }
75
+ }
76
+
77
+ function usableCwd(dir) {
78
+ for (const candidate of [dir, process.cwd(), HOME]) {
79
+ if (!candidate || candidate === '(unknown)') continue;
80
+ try { if (fs.statSync(candidate).isDirectory()) return candidate; } catch { /* skip */ }
81
+ }
82
+ return HOME;
83
+ }
84
+
85
+ function walk(dir, out) {
86
+ out = out || [];
87
+ let entries;
88
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
89
+ for (const entry of entries) {
90
+ const full = path.join(dir, entry.name);
91
+ if (entry.isDirectory()) walk(full, out);
92
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function textFromContent(content) {
98
+ if (typeof content === 'string') return content.trim();
99
+ if (!Array.isArray(content)) return '';
100
+ return content
101
+ .map((item) => {
102
+ if (!item) return '';
103
+ if (typeof item === 'string') return item;
104
+ if (item.type === 'text') return item.text || '';
105
+ if (item.type === 'tool_result') return '';
106
+ return '';
107
+ })
108
+ .filter(Boolean)
109
+ .join(' ')
110
+ .replace(/\s+/g, ' ')
111
+ .trim();
112
+ }
113
+
114
+ function firstUserText(messages) {
115
+ const message = messages.find((msg) => msg.role === 'user');
116
+ return message ? message.text : '';
117
+ }
118
+
119
+ function lastText(messages, role) {
120
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
121
+ if (!role || messages[i].role === role) return messages[i].text;
122
+ }
123
+ return '';
124
+ }
125
+
126
+ function isNoiseUserText(text) {
127
+ return /^<local-command-/.test(text) ||
128
+ /^<command-name>/.test(text) ||
129
+ text.includes('<environment_context>') ||
130
+ text.includes('<permissions instructions>');
131
+ }
132
+
133
+ function emptySession(file, stat) {
134
+ return {
135
+ id: path.basename(file, '.jsonl'),
136
+ file,
137
+ cwd: '(unknown)',
138
+ startedAt: stat.birthtime?.toISOString() || null,
139
+ updatedAt: stat.mtime.toISOString(),
140
+ cliVersion: '',
141
+ provider: '',
142
+ turns: 0,
143
+ first: '',
144
+ last: '',
145
+ lastAssistant: '',
146
+ messages: [],
147
+ backend: 'claude',
148
+ };
149
+ }
150
+
151
+ function parseSession(file) {
152
+ const stat = fs.statSync(file);
153
+ const raw = fs.readFileSync(file, 'utf8').trim();
154
+ if (!raw) return emptySession(file, stat);
155
+
156
+ const messages = [];
157
+ let id = path.basename(file, '.jsonl');
158
+ let cwd = '';
159
+ let startedAt = '';
160
+ let updatedAt = stat.mtime.toISOString();
161
+ let cliVersion = '';
162
+ let provider = '';
163
+ let turns = 0;
164
+
165
+ for (const line of raw.split(/\n/)) {
166
+ let row;
167
+ try { row = JSON.parse(line); } catch { continue; }
168
+
169
+ if (row.sessionId) id = row.sessionId;
170
+ if (row.cwd) cwd = row.cwd;
171
+ if (row.version) cliVersion = row.version;
172
+ if (row.timestamp) {
173
+ if (!startedAt) startedAt = row.timestamp;
174
+ updatedAt = row.timestamp;
175
+ }
176
+
177
+ if ((row.type === 'user' || row.type === 'assistant') && row.message) {
178
+ const role = row.message.role || row.type;
179
+ const text = textFromContent(row.message.content);
180
+ if (!text) continue;
181
+ if (role === 'user' && isNoiseUserText(text)) continue;
182
+ messages.push({ role, text });
183
+ if (role === 'user') turns += 1;
184
+ if (!provider && role === 'assistant' && row.message.model) provider = row.message.model;
185
+ }
186
+ }
187
+
188
+ return {
189
+ id,
190
+ file,
191
+ cwd: cwd || '(unknown)',
192
+ startedAt: startedAt || stat.birthtime?.toISOString() || null,
193
+ updatedAt,
194
+ cliVersion,
195
+ provider,
196
+ turns,
197
+ first: firstUserText(messages),
198
+ last: lastText(messages, 'user'),
199
+ lastAssistant: lastText(messages, 'assistant'),
200
+ messages,
201
+ backend: 'claude',
202
+ };
203
+ }
204
+
205
+ function runArgv(argv, cwd, inherit) {
206
+ const shellCommand = `exec ${argv.map(shellQuote).join(' ')}`;
207
+ const shell = commandShell();
208
+ if (inherit) {
209
+ const child = spawn(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
210
+ child.on('error', (err) => { console.error(`error: failed to start claude: ${err.message}`); process.exit(1); });
211
+ child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); process.exit(code || 0); });
212
+ return undefined;
213
+ }
214
+ const result = spawnSync(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
215
+ if (result.error) throw new Error(`failed to start claude: ${result.error.message}`);
216
+ const status = typeof result.status === 'number' ? result.status : 1;
217
+ process.exitCode = status;
218
+ return status;
219
+ }
220
+
221
+ function runSessionCommand(command, session, args, inherit) {
222
+ const executable = resolveClaudeBin();
223
+ const cwd = usableCwd(session.cwd);
224
+ switch (command) {
225
+ case 'resume': {
226
+ const argv = [executable, '--resume', session.id];
227
+ if (args && args.length) argv.push(args.join(' '));
228
+ return runArgv(argv, cwd, inherit);
229
+ }
230
+ case 'fork': {
231
+ return runArgv([executable, '--resume', session.id, '--fork-session'], cwd, inherit);
232
+ }
233
+ case 'delete': {
234
+ fs.unlinkSync(session.file);
235
+ removeMetadata(session);
236
+ return 0;
237
+ }
238
+ case 'archive':
239
+ case 'unarchive': {
240
+ updateMetadata(session, { archived: command === 'archive' });
241
+ return 0;
242
+ }
243
+ default:
244
+ throw new Error(`Unknown command for claude backend: ${command}`);
245
+ }
246
+ }
247
+
248
+ function runNew(cwd, args, inherit) {
249
+ const argv = [resolveClaudeBin(), ...(args || [])];
250
+ return runArgv(argv, usableCwd(cwd), inherit);
251
+ }
252
+
253
+ function isAvailable() {
254
+ try { return fs.statSync(CLAUDE_PROJECTS_DIR).isDirectory(); } catch { return false; }
255
+ }
256
+
257
+ function getSessionFiles() {
258
+ return walk(CLAUDE_PROJECTS_DIR);
259
+ }
260
+
261
+ function resolveBin() {
262
+ try { return resolveClaudeBin(); } catch { return null; }
263
+ }
264
+
265
+ module.exports = {
266
+ id: 'claude',
267
+ label: 'Claude Code',
268
+ capabilities: {
269
+ new: true,
270
+ resume: true,
271
+ fork: true,
272
+ archive: true,
273
+ unarchive: true,
274
+ delete: true,
275
+ },
276
+ isAvailable,
277
+ getSessionFiles,
278
+ parseSession,
279
+ resolveBin,
280
+ runCommand: runSessionCommand,
281
+ runNew,
282
+ resolveClaudeBin,
283
+ };
@@ -5,9 +5,11 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  const codex = require('./codex');
8
+ const claude = require('./claude');
9
+ const opencode = require('./opencode');
8
10
  const pi = require('./pi');
9
11
 
10
- const ALL_PROVIDERS = [codex, pi];
12
+ const ALL_PROVIDERS = [codex, pi, opencode, claude];
11
13
  const providerMap = new Map(ALL_PROVIDERS.map((p) => [p.id, p]));
12
14
 
13
15
  /**
@@ -41,6 +43,10 @@ function providerForSession(session) {
41
43
  function getAllSessionFiles() {
42
44
  const files = [];
43
45
  for (const provider of getAvailableProviders()) {
46
+ if (provider.listSessions) {
47
+ for (const session of provider.listSessions()) files.push({ session, backend: provider.id });
48
+ continue;
49
+ }
44
50
  for (const file of provider.getSessionFiles()) {
45
51
  files.push({ file, backend: provider.id });
46
52
  }
@@ -54,6 +60,8 @@ module.exports = {
54
60
  getProvider,
55
61
  providerForSession,
56
62
  getAllSessionFiles,
63
+ claude,
57
64
  codex,
65
+ opencode,
58
66
  pi,
59
67
  };
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawn, spawnSync } = require('child_process');
7
+ const { removeMetadata } = require('../model/metadata');
8
+
9
+ const HOME = os.homedir();
10
+ const OPENCODE_DATA_DIR = process.env.OPENCODE_DATA_DIR ||
11
+ path.join(process.env.XDG_DATA_HOME || path.join(HOME, '.local', 'share'), 'opencode');
12
+ const OPENCODE_DB = process.env.OPENCODE_DB || path.join(OPENCODE_DATA_DIR, 'opencode.db');
13
+
14
+ function isExecutable(file) {
15
+ try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
16
+ }
17
+
18
+ function findOnPath(command, pathValue) {
19
+ for (const dir of (pathValue || process.env.PATH || '').split(path.delimiter)) {
20
+ if (!dir) continue;
21
+ const candidate = path.join(dir, command);
22
+ if (isExecutable(candidate)) return candidate;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function shellQuote(value) {
28
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
29
+ }
30
+
31
+ function executableFromOutput(output) {
32
+ for (const line of String(output || '').split(/\r?\n/)) {
33
+ const candidate = line.trim();
34
+ if (candidate && path.isAbsolute(candidate) && isExecutable(candidate)) return candidate;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function runShellLookup(shell, shellArgs, command, env) {
40
+ const result = spawnSync(shell, [...shellArgs, `command -v ${shellQuote(command)}`], {
41
+ encoding: 'utf8',
42
+ env,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ });
45
+ if (result.error || result.status !== 0) return null;
46
+ return executableFromOutput(result.stdout);
47
+ }
48
+
49
+ function findWithShell(command, env) {
50
+ const shell = (env || process.env).SHELL || '/bin/sh';
51
+ if (!isExecutable(shell)) return null;
52
+ return runShellLookup(shell, ['-lc'], command, env) ||
53
+ runShellLookup(shell, ['-ic'], command, env);
54
+ }
55
+
56
+ function resolveOpenCodeBin() {
57
+ if (process.env.OPENCODE_BIN) {
58
+ if (isExecutable(process.env.OPENCODE_BIN)) return process.env.OPENCODE_BIN;
59
+ throw new Error(`OPENCODE_BIN is not executable: ${process.env.OPENCODE_BIN}`);
60
+ }
61
+ const fromPath = findOnPath('opencode', process.env.PATH);
62
+ if (fromPath) return fromPath;
63
+ const fromShell = findWithShell('opencode', process.env);
64
+ if (fromShell) return fromShell;
65
+ throw new Error('Could not find the opencode executable. Set OPENCODE_BIN or add opencode to PATH.');
66
+ }
67
+
68
+ function dbQuery(sql) {
69
+ const result = spawnSync(resolveOpenCodeBin(), ['db', sql, '--format', 'json'], {
70
+ encoding: 'utf8',
71
+ env: process.env,
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ });
74
+ if (result.error) throw result.error;
75
+ if (result.status !== 0) throw new Error((result.stderr || '').trim() || `opencode db exited with code ${result.status}`);
76
+ return JSON.parse(result.stdout || '[]');
77
+ }
78
+
79
+ function sqlString(value) {
80
+ return `'${String(value).replace(/'/g, "''")}'`;
81
+ }
82
+
83
+ function parseJson(value, fallback) {
84
+ try { return JSON.parse(value || ''); } catch { return fallback; }
85
+ }
86
+
87
+ function collectText(value, out = []) {
88
+ if (!value) return out;
89
+ if (typeof value === 'string') {
90
+ if (value.trim()) out.push(value.trim());
91
+ return out;
92
+ }
93
+ if (Array.isArray(value)) {
94
+ for (const item of value) collectText(item, out);
95
+ return out;
96
+ }
97
+ if (typeof value === 'object') {
98
+ for (const key of ['text', 'content', 'prompt', 'message', 'title']) {
99
+ if (Object.prototype.hasOwnProperty.call(value, key)) collectText(value[key], out);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function messageRole(row, data) {
106
+ const type = String(row.type || data.role || data.type || '').toLowerCase();
107
+ if (type.includes('user') || type.includes('input')) return 'user';
108
+ if (type.includes('assistant') || type.includes('agent')) return 'assistant';
109
+ return type || 'message';
110
+ }
111
+
112
+ function listMessages(sessionId) {
113
+ const rows = dbQuery(
114
+ `select type, data from session_message where session_id = ${sqlString(sessionId)} order by seq asc`
115
+ );
116
+ return rows.map((row) => {
117
+ const data = parseJson(row.data, {});
118
+ return {
119
+ role: messageRole(row, data),
120
+ text: collectText(data).join(' ').replace(/\s+/g, ' ').trim(),
121
+ };
122
+ }).filter((message) => message.text);
123
+ }
124
+
125
+ function firstUserText(messages) {
126
+ const message = messages.find((item) => item.role === 'user');
127
+ return message ? message.text : '';
128
+ }
129
+
130
+ function lastText(messages, role) {
131
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
132
+ if (!role || messages[i].role === role) return messages[i].text;
133
+ }
134
+ return '';
135
+ }
136
+
137
+ function millisToIso(value) {
138
+ const number = Number(value);
139
+ if (!Number.isFinite(number) || number <= 0) return null;
140
+ return new Date(number).toISOString();
141
+ }
142
+
143
+ function sessionFromRow(row) {
144
+ const messages = listMessages(row.id);
145
+ const turns = messages.filter((message) => message.role === 'user').length;
146
+ return {
147
+ id: row.id,
148
+ file: '',
149
+ cwd: row.directory || '(unknown)',
150
+ startedAt: millisToIso(row.time_created),
151
+ updatedAt: millisToIso(row.time_updated),
152
+ cliVersion: row.version || '',
153
+ provider: row.model || '',
154
+ turns,
155
+ first: row.title || firstUserText(messages),
156
+ last: lastText(messages, 'user'),
157
+ lastAssistant: lastText(messages, 'assistant'),
158
+ messages,
159
+ archived: Boolean(row.time_archived),
160
+ backend: 'opencode',
161
+ };
162
+ }
163
+
164
+ function listSessions() {
165
+ const rows = dbQuery(
166
+ 'select id, title, directory, version, model, time_created, time_updated, time_archived from session order by time_updated desc'
167
+ );
168
+ return rows.map(sessionFromRow);
169
+ }
170
+
171
+ function usableCwd(dir) {
172
+ for (const candidate of [dir, process.cwd(), HOME]) {
173
+ if (!candidate || candidate === '(unknown)') continue;
174
+ try { if (fs.statSync(candidate).isDirectory()) return candidate; } catch { /* skip */ }
175
+ }
176
+ return HOME;
177
+ }
178
+
179
+ function runArgv(argv, cwd, inherit) {
180
+ if (inherit) {
181
+ const child = spawn(argv[0], argv.slice(1), { stdio: 'inherit', cwd, env: process.env });
182
+ child.on('error', (err) => { console.error(`error: failed to start opencode: ${err.message}`); process.exit(1); });
183
+ child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); process.exit(code || 0); });
184
+ return undefined;
185
+ }
186
+ const result = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit', cwd, env: process.env });
187
+ if (result.error) throw new Error(`failed to start opencode: ${result.error.message}`);
188
+ const status = typeof result.status === 'number' ? result.status : 1;
189
+ process.exitCode = status;
190
+ return status;
191
+ }
192
+
193
+ function runSessionCommand(command, session, args, inherit) {
194
+ const executable = resolveOpenCodeBin();
195
+ const cwd = usableCwd(session.cwd);
196
+ switch (command) {
197
+ case 'resume':
198
+ {
199
+ const argv = [executable, cwd, '--session', session.id];
200
+ if (args && args.length) argv.push('--prompt', args.join(' '));
201
+ return runArgv(argv, cwd, inherit);
202
+ }
203
+ case 'fork':
204
+ return runArgv([executable, cwd, '--session', session.id, '--fork'], cwd, inherit);
205
+ case 'delete': {
206
+ const status = runArgv([executable, 'session', 'delete', session.id], cwd, false);
207
+ if (status === 0) removeMetadata(session);
208
+ return status;
209
+ }
210
+ case 'archive':
211
+ dbQuery(`update session set time_archived = ${Date.now()} where id = ${sqlString(session.id)}`);
212
+ return 0;
213
+ case 'unarchive':
214
+ dbQuery(`update session set time_archived = null where id = ${sqlString(session.id)}`);
215
+ return 0;
216
+ default:
217
+ throw new Error(`Unknown command for opencode backend: ${command}`);
218
+ }
219
+ }
220
+
221
+ function runNew(cwd, args, inherit) {
222
+ const resolvedCwd = usableCwd(cwd);
223
+ const argv = [resolveOpenCodeBin(), resolvedCwd];
224
+ if (args && args.length) argv.push('--prompt', args.join(' '));
225
+ return runArgv(argv, resolvedCwd, inherit);
226
+ }
227
+
228
+ function isAvailable() {
229
+ try {
230
+ resolveOpenCodeBin();
231
+ return fs.statSync(OPENCODE_DB).isFile();
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ function getSessionFiles() {
238
+ return [];
239
+ }
240
+
241
+ function resolveBin() {
242
+ try { return resolveOpenCodeBin(); } catch { return null; }
243
+ }
244
+
245
+ module.exports = {
246
+ id: 'opencode',
247
+ label: 'opencode',
248
+ capabilities: {
249
+ new: true,
250
+ resume: true,
251
+ fork: true,
252
+ archive: true,
253
+ unarchive: true,
254
+ delete: true,
255
+ },
256
+ isAvailable,
257
+ getSessionFiles,
258
+ listSessions,
259
+ resolveBin,
260
+ runCommand: runSessionCommand,
261
+ runNew,
262
+ };
@@ -94,6 +94,7 @@ async function runWorkbench() {
94
94
  mouse: true,
95
95
  keys: true,
96
96
  vi: false,
97
+ tags: true,
97
98
  scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'cyan' } },
98
99
  style: {
99
100
  border: { fg: 'cyan' },
@@ -225,6 +226,19 @@ async function runWorkbench() {
225
226
 
226
227
  const styledListLabel = (color, text) => `{${color}-fg}{bold}${blessed.escape(text)}{/}`;
227
228
 
229
+ const backendThemes = {
230
+ claude: 'yellow',
231
+ codex: 'cyan',
232
+ pi: 'magenta',
233
+ opencode: 'green',
234
+ };
235
+
236
+ const backendLabel = (backend, width = 0) => {
237
+ const text = backend || 'unknown';
238
+ const color = backendThemes[text] || 'yellow';
239
+ return `{${color}-fg}{bold}${blessed.escape(text.padEnd(width))}{/}`;
240
+ };
241
+
228
242
  const machineLabel = (source, count) => {
229
243
  const shortcut = sourceShortcut(source);
230
244
  const prefix = shortcut ? `${shortcut} ` : '';
@@ -248,14 +262,12 @@ async function runWorkbench() {
248
262
  };
249
263
 
250
264
  const sessionLabel = (session) => {
251
- const flags = [
252
- session.backend || '',
253
- session.name ? 'renamed' : '',
254
- session.note ? 'note' : '',
255
- ].filter(Boolean).join(',');
256
265
  const title = session.name || session.first || session.last || '(no prompt)';
257
- const flagText = flags ? `[${flags}]` : '';
258
- return `${shortId(session.id)} ${String(session.turns).padStart(2)}t ${truncate(localTime(session.updatedAt), 18)} ${flagText} ${truncate(title, 88)}`;
266
+ const width = Math.max(24, (screen.width || 80) - projectWidth - 8);
267
+ const backendWidth = Math.max(8, Math.min(12, String(session.backend || 'unknown').length + 2));
268
+ const time = truncate(localTime(session.updatedAt), 18).padEnd(18);
269
+ const detailWidth = Math.max(12, width - backendWidth - 22);
270
+ return `${backendLabel(session.backend, backendWidth)} ${time} ${blessed.escape(truncate(title, detailWidth))}`;
259
271
  };
260
272
 
261
273
  const detailContent = (session) => {