Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ const OMP_AGENT_DIR = process.env.OMP_CODING_AGENT_DIR || path.join(ALL_HOMES[0]
const PI_SESSIONS_DIR = path.join(PI_AGENT_DIR, 'sessions');
const OMP_SESSIONS_DIR = path.join(OMP_AGENT_DIR, 'sessions');
const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3');
const KIRO_SESSIONS_DIR = path.join(ALL_HOMES[0], '.kiro', 'sessions', 'cli');
const COPILOT_SESSION_DIR = path.join(ALL_HOMES[0], '.copilot', 'session-state');
const COPILOT_JB_DIR = path.join(ALL_HOMES[0], '.copilot', 'jb');
const KILO_DB = path.join(ALL_HOMES[0], '.local', 'share', 'kilo', 'kilo.db');
Expand Down Expand Up @@ -1837,6 +1838,89 @@ function loadKiroDetail(conversationId) {
}
}

// ── Kiro CLI (new format: ~/.kiro/sessions/cli/, since ~May 2026) ─────────────

function scanKiroCliSessions() {
const sessions = [];
if (!fs.existsSync(KIRO_SESSIONS_DIR)) return sessions;

let files;
try { files = fs.readdirSync(KIRO_SESSIONS_DIR); } catch { return sessions; }

for (const f of files) {
if (!f.endsWith('.json')) continue;
const sessionId = f.slice(0, -5);
// skip if not a strict UUID name
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) continue;

try {
const meta = JSON.parse(fs.readFileSync(path.join(KIRO_SESSIONS_DIR, f), 'utf8'));
const createdMs = meta.created_at ? new Date(meta.created_at).getTime() : 0;
const updatedMs = meta.updated_at ? new Date(meta.updated_at).getTime() : 0;
const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl');
const fileSize = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;

sessions.push({
id: sessionId,
tool: 'kiro',
format: 'kiro-cli',
project: meta.cwd || '',
project_short: (meta.cwd || '').replace(os.homedir(), '~'),
first_ts: createdMs || Date.now(),
last_ts: updatedMs || Date.now(),
messages: fileSize > 0 ? Math.max(2, Math.floor(fileSize / 3000)) : 0,
first_message: meta.title || '',
has_detail: fs.existsSync(jsonlPath),
file_size: fileSize,
detail_messages: 0,
});
} catch {}
}

return sessions;
}

function loadKiroCliDetail(sessionId) {
// sessionId is untrusted here (resolved from a request param) — require a
// strict UUID before building the path to close a path-traversal vector.
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
return { messages: [] };
}
const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl');
if (!fs.existsSync(jsonlPath)) return { messages: [] };

const messages = [];
try {
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }

const { kind, data } = entry;
if (!data) continue;

if (kind === 'Prompt') {
// data.content is array of {kind, data} blocks
const text = (data.content || [])
.filter(b => b.kind === 'text')
.map(b => b.data || '')
.join('').trim();
if (text) messages.push({ role: 'user', content: text.slice(0, 2000), uuid: data.message_id || '' });

} else if (kind === 'AssistantMessage') {
const text = (data.content || [])
.filter(b => b.kind === 'text')
.map(b => b.data || '')
.join('').trim();
if (text) messages.push({ role: 'assistant', content: text.slice(0, 2000), uuid: data.message_id || '' });
}
}
} catch {}

return { messages: messages.slice(0, 200) };
}

// ── Copilot Chat (VS Code extension) ─────────────────────────

// Build workspace-hash -> project path mapping for VS Code workspaceStorage
Expand Down Expand Up @@ -3468,6 +3552,14 @@ function loadSessions() {
}
} catch {}

// Load Kiro CLI sessions (new format: ~/.kiro/sessions/cli/, since ~May 2026)
try {
const kiroCliSessions = scanKiroCliSessions();
for (const ks of kiroCliSessions) {
sessions[ks.id] = ks;
}
} catch {}

// Load Copilot CLI sessions
try {
const copilotSessions = scanCopilotCliSessions();
Expand Down Expand Up @@ -3737,6 +3829,9 @@ function loadSessionDetail(sessionId, project) {
if (found.format === 'kiro') {
return loadKiroDetail(sessionId);
}
if (found.format === 'kiro-cli') {
return loadKiroCliDetail(sessionId);
}

// Copilot CLI uses JSONL events
if (found.format === 'copilot') {
Expand Down Expand Up @@ -3972,6 +4067,7 @@ function exportSessionMarkdown(sessionId, project) {
found.format === 'cursor' ? loadCursorDetail(sessionId) :
found.format === 'opencode' ? loadOpenCodeDetail(sessionId) :
found.format === 'kiro' ? loadKiroDetail(sessionId) :
found.format === 'kiro-cli' ? loadKiroCliDetail(sessionId) :
found.format === 'kilo' ? loadKiloCliDetail(sessionId) :
found.format === 'qwen' ? loadQwenDetail(sessionId, found.file) :
found.format === 'pi' ? loadPiDetail(sessionId, found.file) :
Expand Down Expand Up @@ -4117,6 +4213,20 @@ function _buildSessionFileIndex() {
} catch {}
}

// Index Kiro CLI file-based sessions (~/.kiro/sessions/cli/, since ~May 2026)
if (fs.existsSync(KIRO_SESSIONS_DIR)) {
try {
for (const f of fs.readdirSync(KIRO_SESSIONS_DIR)) {
if (!f.endsWith('.jsonl')) continue;
const sid = f.slice(0, -6);
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sid)) continue;
if (!_sessionFileIndex[sid]) {
_sessionFileIndex[sid] = { file: path.join(KIRO_SESSIONS_DIR, f), format: 'kiro-cli', sessionId: sid };
}
}
} catch {}
}

_sessionFileIndexTs = now;
}

Expand Down Expand Up @@ -4621,6 +4731,12 @@ function getSessionPreview(sessionId, project, limit) {
return { role: m.role, content: m.content.slice(0, 300) };
});
}
if (found.format === 'kiro-cli') {
var detail = loadKiroCliDetail(sessionId);
return detail.messages.slice(0, limit).map(function(m) {
return { role: m.role, content: m.content.slice(0, 300) };
});
}

// OpenCode: use loadOpenCodeDetail and slice
if (found.format === 'opencode') {
Expand Down Expand Up @@ -4753,6 +4869,13 @@ function buildSearchIndex(sessions) {
texts.push({ role: msg.role, content: msg.content.slice(0, 500) });
}
}
} else if (found.format === 'kiro-cli') {
const detail = loadKiroCliDetail(s.id);
for (const msg of detail.messages) {
if (msg.content && !isSystemMessage(msg.content)) {
texts.push({ role: msg.role, content: msg.content.slice(0, 500) });
}
}
} else if (found.format === 'cursor') {
const detail = loadCursorDetail(s.id);
for (const msg of detail.messages) {
Expand Down Expand Up @@ -4909,6 +5032,18 @@ function getSessionReplay(sessionId, project) {
});
}
}
} else if (found.format === 'kiro-cli') {
const detail = loadKiroCliDetail(sessionId);
for (const msg of detail.messages) {
if (msg.content && !isSystemMessage(msg.content)) {
messages.push({
role: msg.role,
content: msg.content.slice(0, 3000),
timestamp: 0,
ms: 0,
});
}
}
} else if (found.format === 'cursor') {
const detail = loadCursorDetail(sessionId);
for (const msg of detail.messages) {
Expand Down Expand Up @@ -6393,5 +6528,7 @@ module.exports = {
findPiSessionByResumeTarget,
_sessionsNeedRescan,
_updateScanMarkers,
scanKiroCliSessions,
loadKiroCliDetail,
},
};
141 changes: 141 additions & 0 deletions test/kiro-cli-session.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');

// Reload src/data with os.homedir() pointed at a temp home so KIRO_SESSIONS_DIR
// (~/.kiro/sessions/cli) resolves inside the fixture.
function freshDataWithHome(home) {
const dataPath = require.resolve('../src/data');
const handoffPath = require.resolve('../src/handoff');
delete require.cache[handoffPath];
delete require.cache[dataPath];
const oldHome = os.homedir;
os.homedir = () => home;
try {
return require('../src/data');
} finally {
os.homedir = oldHome;
}
}

function tmpHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-kiro-'));
}

// Write a Kiro CLI session pair: <uuid>.json metadata + <uuid>.jsonl events.
function writeKiroCliSession(home, sessionId, meta, events) {
const dir = path.join(home, '.kiro', 'sessions', 'cli');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(meta));
fs.writeFileSync(
path.join(dir, sessionId + '.jsonl'),
events.map(e => JSON.stringify(e)).join('\n') + '\n'
);
}

const UUID = '12345678-90ab-cdef-1234-567890abcdef';

function sampleMeta(cwd) {
return {
session_id: UUID,
cwd,
title: 'Fix the parser',
created_at: '2026-05-24T10:00:00.000Z',
updated_at: '2026-05-24T10:05:00.000Z',
};
}

function sampleEvents() {
return [
{ version: 1, kind: 'Prompt', data: { message_id: 'u1', content: [{ kind: 'text', data: 'Please fix the parser' }] } },
{ version: 1, kind: 'AssistantMessage', data: { message_id: 'a1', content: [{ kind: 'text', data: 'Parser fixed' }] } },
{ version: 1, kind: 'ToolResults', data: { results: [{ ok: true }] } },
];
}

test('scanKiroCliSessions reads metadata files into session summaries', () => {
const home = tmpHome();
writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents());

const data = freshDataWithHome(home);
const sessions = data.__test.scanKiroCliSessions();

assert.equal(sessions.length, 1);
assert.equal(sessions[0].id, UUID);
assert.equal(sessions[0].tool, 'kiro');
assert.equal(sessions[0].format, 'kiro-cli');
assert.equal(sessions[0].project, '/tmp/project');
assert.equal(sessions[0].first_message, 'Fix the parser');
assert.equal(sessions[0].has_detail, true);
assert.equal(sessions[0].first_ts, Date.parse('2026-05-24T10:00:00.000Z'));
assert.equal(sessions[0].last_ts, Date.parse('2026-05-24T10:05:00.000Z'));
});

test('scanKiroCliSessions ignores non-UUID metadata files', () => {
const home = tmpHome();
const dir = path.join(home, '.kiro', 'sessions', 'cli');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'not-a-uuid.json'), JSON.stringify({ title: 'nope' }));

const data = freshDataWithHome(home);
assert.deepEqual(data.__test.scanKiroCliSessions(), []);
});

test('loadKiroCliDetail parses Prompt/AssistantMessage and skips ToolResults', () => {
const home = tmpHome();
writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents());

const data = freshDataWithHome(home);
const detail = data.__test.loadKiroCliDetail(UUID);

assert.equal(detail.messages.length, 2);
assert.deepEqual(detail.messages.map(m => m.role), ['user', 'assistant']);
assert.equal(detail.messages[0].content, 'Please fix the parser');
assert.equal(detail.messages[1].content, 'Parser fixed');
});

test('loadKiroCliDetail rejects path-traversal ids before touching the filesystem', () => {
const home = tmpHome();
writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents());

const data = freshDataWithHome(home);
// A crafted id would resolve outside KIRO_SESSIONS_DIR without the UUID guard.
assert.deepEqual(data.__test.loadKiroCliDetail('../../../../etc/passwd'), { messages: [] });
assert.deepEqual(data.__test.loadKiroCliDetail('..%2f..%2fsecret'), { messages: [] });
assert.deepEqual(data.__test.loadKiroCliDetail(''), { messages: [] });
});

test('findSessionFile resolves file-based Kiro sessions to the kiro-cli format', () => {
const home = tmpHome();
writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents());

const data = freshDataWithHome(home);
const found = data.findSessionFile(UUID, '/tmp/project');

assert.ok(found, 'expected findSessionFile to resolve the kiro-cli session');
assert.equal(found.format, 'kiro-cli');
assert.equal(found.sessionId, UUID);
assert.match(found.file, /\.kiro[\/\\]sessions[\/\\]cli[\/\\]/);
});

test('detail, preview, search, replay, and export are wired end-to-end for kiro-cli', () => {
const home = tmpHome();
writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents());

const data = freshDataWithHome(home);

const detail = data.loadSessionDetail(UUID, '/tmp/project');
assert.deepEqual(detail.messages.map(m => m.content), ['Please fix the parser', 'Parser fixed']);

const preview = data.getSessionPreview(UUID, '/tmp/project', 10);
assert.deepEqual(preview.map(m => m.content), ['Please fix the parser', 'Parser fixed']);

const replay = data.getSessionReplay(UUID, '/tmp/project');
assert.deepEqual(replay.messages.map(m => m.content), ['Please fix the parser', 'Parser fixed']);

const md = data.exportSessionMarkdown(UUID, '/tmp/project');
assert.match(md, /Please fix the parser/);
assert.match(md, /Parser fixed/);
});