codedash-app 3.4.0 → 4.0.0

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/bin/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { loadSessions, searchFullText, getSessionPreview, computeSessionCost } = require('../src/data');
4
4
  const { startServer } = require('../src/server');
5
5
  const { exportArchive, importArchive } = require('../src/migrate');
6
+ const { convertSession } = require('../src/convert');
6
7
 
7
8
  const DEFAULT_PORT = 3847;
8
9
  const args = process.argv.slice(2);
@@ -130,6 +131,44 @@ switch (command) {
130
131
  break;
131
132
  }
132
133
 
134
+ case 'convert': {
135
+ const sid = args[1];
136
+ const target = args[2]; // 'claude' or 'codex'
137
+ if (!sid || !target) {
138
+ console.log(`
139
+ \x1b[36m\x1b[1mConvert session between agents\x1b[0m
140
+
141
+ Usage: codedash convert <session-id> <target-format>
142
+
143
+ Formats: claude, codex
144
+
145
+ Examples:
146
+ codedash convert 019d54ed codex Convert Claude session to Codex
147
+ codedash convert 13ae5748 claude Convert Codex session to Claude
148
+ `);
149
+ break;
150
+ }
151
+ // Find full session ID
152
+ const allConv = loadSessions();
153
+ const match = allConv.find(s => s.id === sid || s.id.startsWith(sid));
154
+ if (!match) {
155
+ console.error(` Session not found: ${sid}`);
156
+ process.exit(1);
157
+ }
158
+ console.log(`\n Converting ${match.tool} session \x1b[1m${match.id.slice(0, 12)}\x1b[0m → ${target}...`);
159
+ const result = convertSession(match.id, match.project, target);
160
+ if (!result.ok) {
161
+ console.error(` \x1b[31mError:\x1b[0m ${result.error}\n`);
162
+ process.exit(1);
163
+ }
164
+ console.log(` \x1b[32mDone!\x1b[0m`);
165
+ console.log(` New session: ${result.target.sessionId}`);
166
+ console.log(` Messages: ${result.target.messages}`);
167
+ console.log(` File: ${result.target.file}`);
168
+ console.log(` Resume: \x1b[2m${result.target.resumeCmd}\x1b[0m\n`);
169
+ break;
170
+ }
171
+
133
172
  case 'update':
134
173
  case 'upgrade': {
135
174
  const { execSync: execU } = require('child_process');
@@ -214,6 +253,7 @@ switch (command) {
214
253
  codedash show <session-id> Show session details + messages
215
254
  codedash list [limit] List sessions in terminal
216
255
  codedash stats Show session statistics
256
+ codedash convert <id> <format> Convert session (claude/codex)
217
257
  codedash export [file.tar.gz] Export all sessions to archive
218
258
  codedash import <file.tar.gz> Import sessions from archive
219
259
  codedash update Update to latest version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codedash-app",
3
- "version": "3.4.0",
3
+ "version": "4.0.0",
4
4
  "description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
5
5
  "bin": {
6
6
  "codedash": "./bin/cli.js"
package/src/convert.js ADDED
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+ const { findSessionFile, extractContent, isSystemMessage } = require('./data');
8
+
9
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
10
+ const CODEX_DIR = path.join(os.homedir(), '.codex');
11
+
12
+ // ── Read session into canonical format ────────────────────
13
+
14
+ function readSession(sessionId, project) {
15
+ const found = findSessionFile(sessionId, project);
16
+ if (!found) return null;
17
+
18
+ const messages = [];
19
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
20
+ let sessionMeta = {};
21
+
22
+ for (const line of lines) {
23
+ try {
24
+ const entry = JSON.parse(line);
25
+
26
+ if (found.format === 'claude') {
27
+ if (entry.type === 'permission-mode') {
28
+ sessionMeta.permissionMode = entry.permissionMode;
29
+ sessionMeta.originalSessionId = entry.sessionId;
30
+ }
31
+ if (entry.type === 'user' || entry.type === 'assistant') {
32
+ const msg = entry.message || {};
33
+ let content = '';
34
+ if (typeof msg.content === 'string') {
35
+ content = msg.content;
36
+ } else if (Array.isArray(msg.content)) {
37
+ content = msg.content
38
+ .filter(b => b.type === 'text')
39
+ .map(b => b.text)
40
+ .join('\n');
41
+ }
42
+ if (!content || isSystemMessage(content)) continue;
43
+
44
+ messages.push({
45
+ role: entry.type === 'user' ? 'user' : 'assistant',
46
+ content: content,
47
+ timestamp: entry.timestamp || '',
48
+ model: msg.model || '',
49
+ });
50
+ }
51
+ } else {
52
+ // Codex
53
+ if (entry.type === 'session_meta' && entry.payload) {
54
+ sessionMeta.cwd = entry.payload.cwd;
55
+ sessionMeta.originalSessionId = entry.payload.id;
56
+ }
57
+ if (entry.type === 'response_item' && entry.payload) {
58
+ const role = entry.payload.role;
59
+ if (role !== 'user' && role !== 'assistant') continue;
60
+ const content = extractContent(entry.payload.content);
61
+ if (!content || isSystemMessage(content)) continue;
62
+
63
+ messages.push({
64
+ role: role,
65
+ content: content,
66
+ timestamp: entry.timestamp || '',
67
+ model: '',
68
+ });
69
+ }
70
+ }
71
+ } catch {}
72
+ }
73
+
74
+ return {
75
+ sourceFormat: found.format,
76
+ sourceFile: found.file,
77
+ sessionId: sessionId,
78
+ meta: sessionMeta,
79
+ messages: messages,
80
+ };
81
+ }
82
+
83
+ // ── Write as Claude Code session ──────────────────────────
84
+
85
+ function writeClaude(canonical, targetProject) {
86
+ const newSessionId = crypto.randomUUID();
87
+ const projectKey = (targetProject || os.homedir()).replace(/[\/\.]/g, '-');
88
+ const projectDir = path.join(CLAUDE_DIR, 'projects', projectKey);
89
+
90
+ if (!fs.existsSync(projectDir)) {
91
+ fs.mkdirSync(projectDir, { recursive: true });
92
+ }
93
+
94
+ const outFile = path.join(projectDir, `${newSessionId}.jsonl`);
95
+ const cwd = targetProject || canonical.meta.cwd || os.homedir();
96
+ const lines = [];
97
+
98
+ // Permission mode entry
99
+ lines.push(JSON.stringify({
100
+ type: 'permission-mode',
101
+ permissionMode: 'default',
102
+ sessionId: newSessionId,
103
+ }));
104
+
105
+ let prevUuid = null;
106
+
107
+ for (const msg of canonical.messages) {
108
+ const uuid = crypto.randomUUID();
109
+ const entry = {
110
+ parentUuid: prevUuid,
111
+ isSidechain: false,
112
+ type: msg.role === 'user' ? 'user' : 'assistant',
113
+ uuid: uuid,
114
+ timestamp: msg.timestamp || new Date().toISOString(),
115
+ userType: 'external',
116
+ entrypoint: 'cli',
117
+ cwd: cwd,
118
+ sessionId: newSessionId,
119
+ version: '2.1.91',
120
+ gitBranch: 'main',
121
+ };
122
+
123
+ if (msg.role === 'user') {
124
+ entry.message = { role: 'user', content: msg.content };
125
+ entry.promptId = crypto.randomUUID();
126
+ } else {
127
+ entry.message = {
128
+ model: msg.model || 'claude-sonnet-4-6',
129
+ id: 'msg_converted_' + uuid.slice(0, 8),
130
+ type: 'message',
131
+ role: 'assistant',
132
+ content: [{ type: 'text', text: msg.content }],
133
+ stop_reason: 'end_turn',
134
+ };
135
+ }
136
+
137
+ lines.push(JSON.stringify(entry));
138
+ prevUuid = uuid;
139
+ }
140
+
141
+ // Write atomically
142
+ const tmpFile = outFile + '.tmp';
143
+ fs.writeFileSync(tmpFile, lines.join('\n') + '\n');
144
+ fs.renameSync(tmpFile, outFile);
145
+
146
+ // Add to history.jsonl
147
+ const historyFile = path.join(CLAUDE_DIR, 'history.jsonl');
148
+ const historyEntry = JSON.stringify({
149
+ sessionId: newSessionId,
150
+ project: cwd,
151
+ timestamp: Date.now(),
152
+ display: `[Converted from ${canonical.sourceFormat}] ${canonical.messages[0]?.content?.slice(0, 100) || ''}`,
153
+ pastedContents: {},
154
+ });
155
+ fs.appendFileSync(historyFile, historyEntry + '\n');
156
+
157
+ return {
158
+ sessionId: newSessionId,
159
+ file: outFile,
160
+ format: 'claude',
161
+ messages: canonical.messages.length,
162
+ resumeCmd: `claude --resume ${newSessionId}`,
163
+ };
164
+ }
165
+
166
+ // ── Write as Codex session ────────────────────────────────
167
+
168
+ function writeCodex(canonical, targetProject) {
169
+ const newSessionId = crypto.randomUUID();
170
+ const now = new Date();
171
+ const dateDir = path.join(
172
+ CODEX_DIR, 'sessions',
173
+ String(now.getFullYear()),
174
+ String(now.getMonth() + 1).padStart(2, '0'),
175
+ String(now.getDate()).padStart(2, '0')
176
+ );
177
+
178
+ if (!fs.existsSync(dateDir)) {
179
+ fs.mkdirSync(dateDir, { recursive: true });
180
+ }
181
+
182
+ const fileName = `rollout-${now.toISOString().replace(/[:.]/g, '-').slice(0, 19)}-${newSessionId}.jsonl`;
183
+ const outFile = path.join(dateDir, fileName);
184
+ const cwd = targetProject || canonical.meta.cwd || os.homedir();
185
+ const lines = [];
186
+
187
+ // Session meta
188
+ lines.push(JSON.stringify({
189
+ timestamp: now.toISOString(),
190
+ type: 'session_meta',
191
+ payload: {
192
+ id: newSessionId,
193
+ timestamp: now.toISOString(),
194
+ cwd: cwd,
195
+ originator: 'codex_cli_rs',
196
+ cli_version: '0.101.0',
197
+ source: 'cli',
198
+ model_provider: 'openai',
199
+ },
200
+ }));
201
+
202
+ // Messages
203
+ for (const msg of canonical.messages) {
204
+ lines.push(JSON.stringify({
205
+ timestamp: msg.timestamp || now.toISOString(),
206
+ type: 'response_item',
207
+ payload: {
208
+ type: 'message',
209
+ role: msg.role,
210
+ content: [{ type: 'input_text', text: msg.content }],
211
+ },
212
+ }));
213
+ }
214
+
215
+ // Write atomically
216
+ const tmpFile = outFile + '.tmp';
217
+ fs.writeFileSync(tmpFile, lines.join('\n') + '\n');
218
+ fs.renameSync(tmpFile, outFile);
219
+
220
+ // Add to codex history
221
+ const historyFile = path.join(CODEX_DIR, 'history.jsonl');
222
+ if (!fs.existsSync(path.dirname(historyFile))) {
223
+ fs.mkdirSync(path.dirname(historyFile), { recursive: true });
224
+ }
225
+ const historyEntry = JSON.stringify({
226
+ session_id: newSessionId,
227
+ ts: Math.floor(Date.now() / 1000),
228
+ text: `[Converted from ${canonical.sourceFormat}] ${canonical.messages[0]?.content?.slice(0, 100) || ''}`,
229
+ });
230
+ fs.appendFileSync(historyFile, historyEntry + '\n');
231
+
232
+ return {
233
+ sessionId: newSessionId,
234
+ file: outFile,
235
+ format: 'codex',
236
+ messages: canonical.messages.length,
237
+ resumeCmd: `codex resume ${newSessionId}`,
238
+ };
239
+ }
240
+
241
+ // ── Main convert function ─────────────────────────────────
242
+
243
+ function convertSession(sessionId, project, targetFormat) {
244
+ const canonical = readSession(sessionId, project);
245
+ if (!canonical) {
246
+ return { ok: false, error: 'Session not found' };
247
+ }
248
+
249
+ if (canonical.sourceFormat === targetFormat) {
250
+ return { ok: false, error: `Session is already in ${targetFormat} format` };
251
+ }
252
+
253
+ if (canonical.messages.length === 0) {
254
+ return { ok: false, error: 'Session has no messages to convert' };
255
+ }
256
+
257
+ let result;
258
+ if (targetFormat === 'claude') {
259
+ result = writeClaude(canonical, project);
260
+ } else if (targetFormat === 'codex') {
261
+ result = writeCodex(canonical, project);
262
+ } else {
263
+ return { ok: false, error: `Unknown target format: ${targetFormat}` };
264
+ }
265
+
266
+ return {
267
+ ok: true,
268
+ source: { format: canonical.sourceFormat, sessionId: sessionId, messages: canonical.messages.length },
269
+ target: result,
270
+ };
271
+ }
272
+
273
+ module.exports = { convertSession, readSession };
package/src/data.js CHANGED
@@ -829,6 +829,9 @@ module.exports = {
829
829
  getCostAnalytics,
830
830
  computeSessionCost,
831
831
  MODEL_PRICING,
832
+ findSessionFile,
833
+ extractContent,
834
+ isSystemMessage,
832
835
  CLAUDE_DIR,
833
836
  CODEX_DIR,
834
837
  HISTORY_FILE,
@@ -1081,6 +1081,8 @@ async function openDetail(s) {
1081
1081
  if (s.has_detail) {
1082
1082
  infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Replay</button>';
1083
1083
  infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
1084
+ var convertTarget = s.tool === 'codex' ? 'claude' : 'codex';
1085
+ infoHtml += '<button class="launch-btn btn-secondary" onclick="convertTo(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\',\'' + convertTarget + '\')">Convert to ' + convertTarget + '</button>';
1084
1086
  }
1085
1087
  infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + s.id + '\')">&#9733; ' + (isStarred ? 'Starred' : 'Star') + '</button>';
1086
1088
  infoHtml += '<button class="launch-btn btn-delete" onclick="showDeleteConfirm(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Delete</button>';
@@ -1744,6 +1746,31 @@ function focusSession(sessionId) {
1744
1746
  });
1745
1747
  }
1746
1748
 
1749
+ // ── Convert session ───────────────────────────────────────────
1750
+
1751
+ async function convertTo(sessionId, project, targetFormat) {
1752
+ if (!confirm('Convert this session to ' + targetFormat + '? A new session will be created.')) return;
1753
+ showToast('Converting...');
1754
+ try {
1755
+ var resp = await fetch('/api/convert', {
1756
+ method: 'POST',
1757
+ headers: { 'Content-Type': 'application/json' },
1758
+ body: JSON.stringify({ sessionId: sessionId, project: project, targetFormat: targetFormat }),
1759
+ });
1760
+ var data = await resp.json();
1761
+ if (data.ok) {
1762
+ showToast('Converted! New session: ' + data.target.sessionId.slice(0, 12));
1763
+ // Refresh to show new session
1764
+ await loadSessions();
1765
+ closeDetail();
1766
+ } else {
1767
+ showToast('Error: ' + (data.error || 'unknown'));
1768
+ }
1769
+ } catch (e) {
1770
+ showToast('Convert failed: ' + e.message);
1771
+ }
1772
+ }
1773
+
1747
1774
  // ── Install agents ────────────────────────────────────────────
1748
1775
 
1749
1776
  var AGENT_INSTALL = {
package/src/server.js CHANGED
@@ -5,6 +5,7 @@ const { URL } = require('url');
5
5
  const { exec } = require('child_process');
6
6
  const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost } = require('./data');
7
7
  const { detectTerminals, openInTerminal, focusTerminalByPid } = require('./terminals');
8
+ const { convertSession } = require('./convert');
8
9
  const { getHTML } = require('./html');
9
10
 
10
11
  function startServer(port, openBrowser = true) {
@@ -110,6 +111,19 @@ function startServer(port, openBrowser = true) {
110
111
  json(res, active);
111
112
  }
112
113
 
114
+ // ── Convert session ─────────────────────
115
+ else if (req.method === 'POST' && pathname === '/api/convert') {
116
+ readBody(req, body => {
117
+ try {
118
+ const { sessionId, project, targetFormat } = JSON.parse(body);
119
+ const result = convertSession(sessionId, project || '', targetFormat);
120
+ json(res, result);
121
+ } catch (e) {
122
+ json(res, { ok: false, error: e.message }, 400);
123
+ }
124
+ });
125
+ }
126
+
113
127
  // ── Focus terminal ──────────────────────
114
128
  else if (req.method === 'POST' && pathname === '/api/focus') {
115
129
  readBody(req, body => {