Skip to main content

qcode/profile/
history.rs

1//! A harness's own record of the conversations it had in a workspace, and the line that opens one
2//! of them again.
3//!
4//! Every harness keeps its conversations under its home directory, which in QCode is the
5//! workspace's home volume for the profile. The host cannot read a volume by itself (Docker keeps
6//! its volumes where only root reaches), so the reading happens inside the profile's own
7//! container: the base image carries Node, and each harness has one short script, run with
8//! `node -e`, that knows where that harness writes and prints what it found. The script is the
9//! only part that knows a harness's file format; everything it prints has one shape for all of
10//! them, and [`parse`] is where that shape is checked.
11//!
12//! Each script is given two directories: where the workspace's files are today ([`CODE_DIR`])
13//! and where a QCode written earlier mounted them ([`LEGACY_CODE_DIR`]). A harness writes the
14//! working directory it was in into every conversation it records, so a conversation had before
15//! the mount was renamed names the old directory and only the old one; a script that knew a
16//! single name would leave every one of them out and the person would open the history list to
17//! find it empty. Both are looked under, and a conversation found twice is kept once.
18//!
19//! What a script prints, one line per conversation of those directories and of no
20//! other: the conversation's id, a tab, when it was last used in Unix milliseconds, a tab, and
21//! its title, which may be empty. A missing folder, an unreadable file or a record in a shape the
22//! script does not know is left out rather than guessed at, and nothing a script meets makes it
23//! fail: a harness that was never used in the workspace has simply had no conversations there.
24//! Each script reads files a line at a time, never holds a line longer than a mebibyte and stops
25//! a file after 256 MiB, so a transcript grown huge costs time but never the container's memory.
26//!
27//! Where each harness keeps its conversations was read from its documentation and source and
28//! checked in throwaway containers against Claude Code 2.1.276, opencode 1.18.31, Gemini CLI
29//! 0.60.0, Codex 0.155.0, Kimi Code CLI 2.1.0 and Qwen Code 0.24.4; the script of each one says
30//! what it relies on, and `history_live.rs` runs every script against records written the way
31//! those versions write them.
32
33use super::HarnessKind;
34use crate::base::paths::{CODE_DIR, LEGACY_CODE_DIR};
35use crate::engine::run::{EngineError, capture};
36use crate::engine::{ContainerState, Engine, EngineCommand, Exec};
37use std::collections::HashSet;
38
39/// The most characters of a title that are kept. A title is a line in a list; the first words
40/// say which conversation it is, and a first prompt pasted whole would say nothing more.
41pub const TITLE_CHARS: usize = 200;
42
43/// The longest id that is taken. Every harness here writes ids of 36 characters or fewer; one
44/// far longer is not an id any of them made.
45const ID_CHARS: usize = 128;
46
47/// One conversation a harness had in the workspace.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Conversation {
50    /// The harness's own id for it, the word its resume argument takes.
51    pub id: String,
52    /// What the harness or the person named it, or failing that its first prompt; `None` when
53    /// it has neither.
54    pub title: Option<String>,
55    /// When it was last used, in milliseconds since the Unix epoch.
56    pub used_ms: i64,
57}
58
59/// Whether `id` can be handed to a harness on its command line as a conversation's id.
60///
61/// The id is read from files the harness and whatever ran inside the container wrote, so it is
62/// held to the shapes the harnesses really make (UUIDs, and opencode's `ses_` followed by
63/// letters and digits) rather than trusted: only ASCII letters, digits, `-` and `_`, starting
64/// with a letter or a digit so it can never be read as an option, and no longer than any of
65/// them writes.
66#[must_use]
67pub fn is_safe_id(id: &str) -> bool {
68    id.len() <= ID_CHARS
69        && id.chars().next().is_some_and(|first| first.is_ascii_alphanumeric())
70        && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
71}
72
73/// Reads what a harness's script printed: every conversation it names, newest first.
74///
75/// A line that is not an id, a time and a title is left out, and so is one whose id could not
76/// be passed on safely ([`is_safe_id`]). A title has its control characters turned into spaces
77/// again, its runs of white space made one space, and is cut to [`TITLE_CHARS`]; one that is
78/// empty after that is no title. An id that comes twice is kept once, with its newest use.
79#[must_use]
80pub fn parse(output: &str) -> Vec<Conversation> {
81    let mut found: Vec<Conversation> = output.lines().filter_map(conversation).collect();
82    found.sort_by(|a, b| b.used_ms.cmp(&a.used_ms).then_with(|| a.id.cmp(&b.id)));
83    let mut seen = HashSet::new();
84    found.retain(|conversation| seen.insert(conversation.id.clone()));
85    found
86}
87
88/// One line of a script's output, when it is one.
89fn conversation(line: &str) -> Option<Conversation> {
90    let mut fields = line.splitn(3, '\t');
91    let id = fields.next()?.trim();
92    let used_ms = fields.next()?.trim().parse().ok()?;
93    let title = fields.next().unwrap_or_default();
94    is_safe_id(id).then(|| Conversation { id: id.to_owned(), title: tidy(title), used_ms })
95}
96
97/// A title as a list shows it: one line, single spaces, not too long.
98fn tidy(title: &str) -> Option<String> {
99    let words: Vec<&str> =
100        title.split(|c: char| c.is_whitespace() || c.is_control()).filter(|w| !w.is_empty()).collect();
101    let joined = words.join(" ");
102    let cut: String = joined.chars().take(TITLE_CHARS).collect();
103    let cut = cut.trim_end();
104    (!cut.is_empty()).then(|| cut.to_owned())
105}
106
107/// What every script starts with: where to look, how to print, and a line reader that neither
108/// throws nor holds more than it must.
109macro_rules! prelude {
110    () => {
111        r#"const fs = require('fs'), path = require('path'), os = require('os');
112const workspace = process.argv[1] || '', was = process.argv[2] || '', home = os.homedir();
113const workspaces = [workspace, was].filter((d, i, all) => d && all.indexOf(d) === i);
114const here = (d) => typeof d === 'string' && workspaces.includes(d);
115const LINE_CAP = 1 << 20, FILE_CAP = 256 << 20, TITLE_CAP = 1000;
116const clean = (s) => typeof s === 'string' ? s.replace(/[\p{Cc}\p{Zl}\p{Zp}]+/gu, ' ').trim().slice(0, TITLE_CAP) : '';
117const emit = (id, ms, title) => {
118  if (typeof id !== 'string' || !id) return;
119  const at = Number.isFinite(ms) ? Math.floor(ms) : 0;
120  process.stdout.write(clean(id) + '\t' + at + '\t' + clean(title) + '\n');
121};
122const json = (s) => { try { return JSON.parse(s); } catch (e) { return undefined; } };
123const text = (c) => typeof c === 'string' ? c : Array.isArray(c) ? c.map((p) => p && typeof p.text === 'string' ? p.text : '').join('') : '';
124const stat = (f) => { try { return fs.statSync(f); } catch (e) { return undefined; } };
125const list = (d) => { try { return fs.readdirSync(d); } catch (e) { return []; } };
126const small = (f) => { const s = stat(f); if (!s || !s.isFile() || s.size > LINE_CAP) return ''; try { return fs.readFileSync(f, 'utf8'); } catch (e) { return ''; } };
127const lines = (file, each) => {
128  let fd;
129  try { fd = fs.openSync(file, 'r'); } catch (e) { return; }
130  const buf = Buffer.alloc(1 << 16);
131  let parts = [], size = 0, over = false, total = 0;
132  const take = (piece) => { if (over) return; if (size + piece.length > LINE_CAP) { over = true; parts = []; size = 0; return; } parts.push(Buffer.from(piece)); size += piece.length; };
133  const end = () => { const line = over ? null : Buffer.concat(parts).toString('utf8'); parts = []; size = 0; over = false; return line === null ? undefined : each(line); };
134  try {
135    while (total < FILE_CAP) {
136      const n = fs.readSync(fd, buf, 0, buf.length, null);
137      if (n <= 0) break;
138      total += n;
139      let start = 0;
140      for (let i = 0; i < n; i++) {
141        if (buf[i] !== 10) continue;
142        take(buf.subarray(start, i));
143        start = i + 1;
144        if (end() === false) return;
145      }
146      take(buf.subarray(start, n));
147    }
148    if (size > 0) end();
149  } catch (e) {
150  } finally {
151    try { fs.closeSync(fd); } catch (e) {}
152  }
153};
154"#
155    };
156}
157
158/// Claude Code keeps one file per conversation, `~/.claude/projects/<workspace>/<id>.jsonl`, where
159/// `<workspace>` is the working directory with every character that is not a letter or a digit
160/// made `-` (`/work` is `-work`; a path over 200 characters would be cut and
161/// hashed, which neither [`CODE_DIR`] nor [`LEGACY_CODE_DIR`] is). Both directories have a
162/// folder of their own, so both are listed. The session docs
163/// (`code.claude.com/docs/en/sessions`) name the picker's order: a name the person gave, a
164/// summary, the first prompt. A name is a `{"type":"custom-title","customTitle":...}` line (the
165/// last one counts), older versions wrote `{"type":"summary","summary":...}`, and the first
166/// prompt is the first `{"type":"user","message":{"content":...}}` line whose content is text,
167/// as a string or as `{"type":"text","text":...}` parts; one starting with `<` is a command's
168/// output the harness put there, not something the person typed. A file with no line naming a
169/// `sessionId` holds no conversation the harness could open. The file's change time is when the
170/// conversation was last used.
171const CLAUDE_CODE: &str = concat!(
172    prelude!(),
173    r#"try {
174  const found = [];
175  for (const w of workspaces) {
176    const dir = path.join(home, '.claude', 'projects', w.replace(/[^A-Za-z0-9]/g, '-'));
177    for (const name of list(dir)) found.push([dir, name]);
178  }
179  for (const [dir, name] of found) {
180    const file = path.join(dir, name), s = stat(file);
181    if (!name.endsWith('.jsonl') || !s || !s.isFile()) continue;
182    let custom = '', summary = '', first = '', any = false;
183    lines(file, (l) => {
184      any = any || l.includes('"sessionId"');
185      if (!l.includes('"custom-title"') && !l.includes('"summary"') && (first || !l.includes('"user"'))) return;
186      const r = json(l);
187      if (!r || typeof r !== 'object') return;
188      if (r.type === 'custom-title' && typeof r.customTitle === 'string') custom = r.customTitle;
189      else if (r.type === 'summary' && typeof r.summary === 'string') summary = r.summary;
190      else if (r.type === 'user' && !first && !r.isMeta && !r.isSidechain && r.message) {
191        const t = text(r.message.content).trim();
192        if (t && !t.startsWith('<')) first = t;
193      }
194    });
195    if (any) emit(name.slice(0, -'.jsonl'.length), s.mtimeMs, clean(custom) || clean(summary) || first);
196  }
197} catch (e) {}
198"#
199);
200
201/// opencode keeps its conversations in a database of its own
202/// (`~/.local/share/opencode/opencode.db`), so it is asked instead: `opencode session list
203/// --format json` (`opencode.ai/docs/cli`) prints `[{"id","title","updated","created",
204/// "projectId","directory"}]` with times in milliseconds, and nothing at all when there are
205/// none. It lists by workspace, and a folder git will not vouch for (the mounted workspace belongs
206/// to another user as far as git inside can tell) falls into one `global` workspace shared with
207/// every other such folder, so the list is narrowed to `directory` here — to the workspace's
208/// directory today or the one an older QCode mounted it on. A conversation nobody
209/// named keeps the title `New session - <ISO time>` (`Child session - ` for one the harness
210/// opened on its own), which says nothing a list's time column does not, so it counts as no title.
211/// `--pure` keeps plugins the person configured from loading just to list sessions.
212const OPENCODE: &str = concat!(
213    prelude!(),
214    r#"try {
215  let out = '';
216  try {
217    out = require('child_process').execFileSync('opencode', ['session', 'list', '--format', 'json', '--pure'], {
218      cwd: workspace, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 60000,
219    });
220  } catch (e) {}
221  const all = json(out.slice(Math.max(out.indexOf('['), 0)));
222  for (const s of Array.isArray(all) ? all : []) {
223    if (!s || !here(s.directory)) continue;
224    const placeholder = typeof s.title === 'string' && /^(New|Child) session - \d{4}-\d\d-\d\dT[\d:.]+Z$/.test(s.title);
225    emit(s.id, s.updated, placeholder ? '' : s.title);
226  }
227} catch (e) {}
228"#
229);
230
231/// Gemini CLI (`geminicli.com/docs/cli/session-management`) keeps a folder per workspace,
232/// `~/.gemini/tmp/<slug>/chats/` — one folder per directory, so the workspace as it is mounted
233/// today and as an older QCode mounted it each have their own — with the slug for a path in
234/// `~/.gemini/projects.json`
235/// (`{"projects":{"<path>":"<slug>"}}`) and the path again in `~/.gemini/tmp/<slug>/.project_root`.
236/// A conversation is a `session-*.jsonl` file (`.json` in older versions, one object); its first
237/// line holds `sessionId`, `projectHash`, `startTime` and `lastUpdated`, each message is a line
238/// with an `id`, `{"$set":{...}}` lines update the first (`summary`, `lastUpdated`) and
239/// `{"$rewindTo":<id>}` drops that message and every one after it. This follows
240/// `loadConversationRecord` and `getAllSessionFiles` in the 0.60.0 bundle: a conversation
241/// without a message worth resuming (a user message that is not empty and not a `/` or `?`
242/// command, or an answer) is one the harness itself would not offer, conversations whose
243/// `kind` is not `main` (the harness's own helpers) are left out, and the title is the summary or else the first such user message.
244const GEMINI_CLI: &str = concat!(
245    prelude!(),
246    r#"try {
247  const root = path.join(home, '.gemini');
248  const known = json(small(path.join(root, 'projects.json')));
249  const slugs = [];
250  for (const w of workspaces) {
251    let slug = known && known.projects && typeof known.projects[w] === 'string' ? known.projects[w] : '';
252    if (!slug) slug = list(path.join(root, 'tmp')).find((d) => small(path.join(root, 'tmp', d, '.project_root')).trim() === w) || '';
253    if (slug && !slug.includes('/') && slug !== '.' && slug !== '..' && !slugs.includes(slug)) slugs.push(slug);
254  }
255  const found = [];
256  for (const slug of slugs) {
257    const dir = path.join(root, 'tmp', slug, 'chats');
258    for (const name of list(dir)) found.push([dir, name]);
259  }
260  const message = (m) => {
261    const t = text(m.content).trim();
262    const real = m.type === 'user'
263      ? t.length > 0 && !/^(\/|\?|<session_context>|<hook_context>)/.test(t)
264      : m.type === 'gemini' && (t.length > 0 || (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) || (Array.isArray(m.thoughts) && m.thoughts.length > 0));
265    return { id: m.id, user: m.type === 'user', text: t, real };
266  };
267  const messages = (all) => Array.isArray(all) ? all.filter((m) => m && typeof m.id === 'string').map(message) : [];
268  for (const [dir, name] of found) {
269    const file = path.join(dir, name), s = stat(file);
270    if (!name.startsWith('session-') || !/\.jsonl?$/.test(name) || !s || !s.isFile()) continue;
271    let meta = {}, said = [];
272    const record = (r) => {
273      if (!r || typeof r !== 'object') return;
274      if (typeof r.$rewindTo === 'string') {
275        const at = said.findIndex((m) => m.id === r.$rewindTo);
276        said = at >= 0 ? said.slice(0, at) : [];
277      } else if (typeof r.id === 'string') {
278        said.push(message(r));
279      } else if (r.$set && typeof r.$set === 'object') {
280        if (Array.isArray(r.$set.messages)) said = messages(r.$set.messages);
281        meta = { ...meta, ...r.$set, messages: undefined };
282      } else if (typeof r.sessionId === 'string' && typeof r.projectHash === 'string') {
283        said = said.concat(messages(r.messages));
284        meta = { ...meta, ...r, messages: undefined };
285      }
286    };
287    lines(file, (l) => { if (l.trim()) record(json(l)); });
288    if ((!meta.sessionId || !meta.projectHash) && s.size <= FILE_CAP / 4) {
289      meta = {};
290      said = [];
291      try { record(json(fs.readFileSync(file, 'utf8'))); } catch (e) {}
292    }
293    if (typeof meta.sessionId !== 'string' || (meta.kind !== undefined && meta.kind !== 'main') || !said.some((m) => m.real)) continue;
294    const first = said.find((m) => m.user && m.real);
295    const used = Date.parse(meta.lastUpdated) || Date.parse(meta.startTime) || s.mtimeMs;
296    emit(meta.sessionId, used, clean(meta.summary) || (first ? first.text : ''));
297  }
298} catch (e) {}
299"#
300);
301
302/// Codex keeps every workspace's conversations together, as
303/// `~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl` (`codex-rs/rollout/src/lib.rs` in
304/// `github.com/openai/codex`), so each file's first line is read: `{"type":"session_meta",
305/// "payload":{"id","cwd",...}}`, and only those whose `cwd` is the workspace — as it is
306/// mounted today or as an older QCode mounted it — count. A name given
307/// with `/rename` is in `~/.codex/session_index.jsonl` as `{"id","thread_name","updated_at"}`,
308/// the last line for an id winning (`codex-rs/rollout/src/session_index.rs`). Without one the
309/// title is the first thing the person said: a `user_message` event, or a user
310/// `response_item` message that is not one of the blocks Codex puts in itself, which all start
311/// with a tag such as `<environment_context>` or with a heading naming an instructions file
312/// (`# <FILE>.md instructions`). The file's
313/// change time is when the conversation was last used. `CODEX_HOME` moves the whole folder, as
314/// it does for Codex.
315const CODEX: &str = concat!(
316    prelude!(),
317    r#"try {
318  const root = process.env.CODEX_HOME || path.join(home, '.codex');
319  const names = new Map();
320  lines(path.join(root, 'session_index.jsonl'), (l) => {
321    const r = json(l);
322    if (r && typeof r.id === 'string' && typeof r.thread_name === 'string') names.set(r.id, r.thread_name);
323  });
324  const injected = /^(<[A-Za-z_][\w-]*>|# [A-Za-z_-]+\.md instructions)/;
325  const walk = (dir, depth) => {
326    for (const name of list(dir)) {
327      const file = path.join(dir, name), s = stat(file);
328      if (!s) continue;
329      if (s.isDirectory()) { if (depth < 4) walk(file, depth + 1); continue; }
330      if (!s.isFile() || !name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
331      let id = '', first = '', seen = 0;
332      lines(file, (l) => {
333        const r = json(l);
334        if (seen++ === 0) {
335          const p = r && r.type === 'session_meta' && r.payload;
336          if (!p || !here(p.cwd) || typeof p.id !== 'string') return false;
337          id = p.id;
338          return names.has(id) ? false : undefined;
339        }
340        const p = r && r.payload;
341        if (!p) return;
342        let t = '';
343        if (r.type === 'event_msg' && p.type === 'user_message') t = typeof p.message === 'string' ? p.message.trim() : '';
344        else if (r.type === 'response_item' && p.type === 'message' && p.role === 'user') t = text(p.content).trim();
345        if (t && !injected.test(t)) { first = t; return false; }
346      });
347      if (id) emit(id, s.mtimeMs, clean(names.get(id)) || first);
348    }
349  };
350  walk(path.join(root, 'sessions'), 0);
351} catch (e) {}
352"#
353);
354
355/// Kimi Code CLI keeps its sessions in a store of its own under `~/.kimi-code/sessions/`, indexed
356/// by a small database beside it, so it is asked instead: `kimi session list --cwd <folder> --json`
357/// (`kimi session list --help`) prints `[{"id","title","lastPrompt","workDir","updatedAt",
358/// "metadata",…}]` with times in milliseconds, `[]` for a folder it never worked in, and leaves
359/// out a field it has no value for. Each workspace directory is asked in turn. A title is what the
360/// harness or the person named the session; without one, the last thing the person asked stands
361/// for it. A session the harness opened for one of its own helpers says so in
362/// `metadata.child_session_kind` and is left out, as its own list leaves it out.
363const KIMI_CODE: &str = concat!(
364    prelude!(),
365    r#"try {
366  for (const w of workspaces) {
367    let out = '';
368    try {
369      out = require('child_process').execFileSync('kimi', ['session', 'list', '--cwd', w, '--json'], {
370        encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 60000,
371      });
372    } catch (e) {}
373    const all = json(out.slice(Math.max(out.indexOf('['), 0)));
374    for (const s of Array.isArray(all) ? all : []) {
375      if (!s || !here(s.workDir) || s.archived === true) continue;
376      if (s.metadata && s.metadata.child_session_kind === 'child') continue;
377      emit(s.id, s.updatedAt, clean(s.title) || clean(s.lastPrompt));
378    }
379  }
380} catch (e) {}
381"#
382);
383
384/// Qwen Code keeps a file per conversation, `~/.qwen/projects/<folder>/chats/<id>.jsonl`, and
385/// lists them itself: `qwen sessions list --json` prints one line per conversation of the folder it
386/// runs in, `{"sessionId","mtime","prompt","customTitle","cwd",…}`, with `mtime` in milliseconds,
387/// at most `--limit` of them, twenty unless told otherwise. Each workspace directory is asked in
388/// its own folder, and the line's `cwd` is checked all the same. A title the person gave comes
389/// first, then the first thing they asked.
390const QWEN_CODE: &str = concat!(
391    prelude!(),
392    r#"try {
393  for (const w of workspaces) {
394    let out = '';
395    try {
396      out = require('child_process').execFileSync('qwen', ['sessions', 'list', '--json', '--limit', '1000'], {
397        cwd: w, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 60000,
398      });
399    } catch (e) {}
400    for (const l of out.split('\n')) {
401      const s = json(l);
402      if (!s || typeof s !== 'object' || !here(s.cwd)) continue;
403      emit(s.sessionId, s.mtime, clean(s.customTitle) || clean(s.prompt));
404    }
405  }
406} catch (e) {}
407"#
408);
409
410impl HarnessKind {
411    /// The Node script that prints this harness's conversations in the workspace whose folder is
412    /// its first argument, in the shape [`parse`] reads.
413    ///
414    /// `None` for a harness whose conversations QCode cannot list: the window harness keeps its
415    /// own, under [`HarnessKind::conversation_paths`], in a shape that was never read, and a list
416    /// guessed at would offer conversations that do not open.
417    #[must_use]
418    pub fn history_script(self) -> Option<&'static str> {
419        match self {
420            Self::ClaudeCode => Some(CLAUDE_CODE),
421            Self::OpenCode => Some(OPENCODE),
422            Self::GeminiCli => Some(GEMINI_CLI),
423            Self::Codex => Some(CODEX),
424            Self::KimiCode => Some(KIMI_CODE),
425            Self::QwenCode => Some(QWEN_CODE),
426            Self::AntigravityIde => None,
427        }
428    }
429
430    /// Where this harness keeps its conversations, relative to the home directory: the folders
431    /// and files the scripts above read, and nothing beside them.
432    ///
433    /// This is what a backup of a profile's conversations takes, and it is a list of what may
434    /// go rather than of what may not: the backup is a plain folder on the machine, and a new
435    /// harness version that puts its login somewhere new would slip past a list of what to
436    /// leave out, never past this one. opencode's list is its database and the two journals
437    /// SQLite keeps beside it, because the three only make one database together.
438    #[must_use]
439    pub fn conversation_paths(self) -> &'static [&'static str] {
440        match self {
441            Self::ClaudeCode => &[".claude/projects"],
442            Self::OpenCode => &[
443                ".local/share/opencode/opencode.db",
444                ".local/share/opencode/opencode.db-wal",
445                ".local/share/opencode/opencode.db-shm",
446            ],
447            Self::GeminiCli => &[".gemini/tmp", ".gemini/projects.json"],
448            Self::Codex => &[".codex/sessions", ".codex/session_index.jsonl"],
449            // The sessions and the index Kimi Code lists them by; `cache/` holds the search
450            // index it rebuilds from them, and `credentials/` the login, so neither goes.
451            Self::KimiCode => &[".kimi-code/sessions", ".kimi-code/session_index.jsonl"],
452            // One folder per workspace directory, holding `chats/`; the settings and the rest of
453            // `~/.qwen` stay out.
454            Self::QwenCode => &[".qwen/projects"],
455            // The agent inside the window keeps its work here: the trial found `conversations/`,
456            // `brain/`, `knowledge/` and `html_artifacts/` under it after one session. QCode does
457            // not read the shape of any of it; it only knows the folder, which is what a backup
458            // needs. The editor's own state lives elsewhere and stays out.
459            Self::AntigravityIde => &[".gemini/antigravity-ide"],
460        }
461    }
462
463    /// The database this harness keeps its conversations in, when it keeps them in one, relative
464    /// to the home directory. Its journals are the same path with `-wal` and `-shm` after it.
465    ///
466    /// A database copied while the harness writes to it can be a torn copy, and its journal only
467    /// fits the database it was written with; so a harness with one is only backed up and
468    /// brought back while it is stopped.
469    #[must_use]
470    pub fn conversation_database(self) -> Option<&'static str> {
471        match self {
472            Self::OpenCode => Some(".local/share/opencode/opencode.db"),
473            Self::ClaudeCode
474            | Self::GeminiCli
475            | Self::Codex
476            | Self::KimiCode
477            | Self::QwenCode
478            | Self::AntigravityIde => None,
479        }
480    }
481}
482
483/// The commands that read a harness's conversations out of a workspace's container for its
484/// profile.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct Reading {
487    /// Asks whether the container is there and whether it runs.
488    pub state: EngineCommand,
489    /// Starts it, when it is there but stopped.
490    pub start: EngineCommand,
491    /// Wakes it, when it is there and frozen.
492    pub wake: EngineCommand,
493    /// Runs the harness's script in it, as the user the container runs as, without a terminal.
494    pub list: EngineCommand,
495}
496
497/// Spells out the commands that read `harness`'s conversations from `container`, or `None` for a
498/// harness whose conversations QCode cannot list.
499///
500/// The script is given both the directory the workspace is mounted on today and the one an older
501/// QCode mounted it on, so a conversation recorded before the change is still found.
502#[must_use]
503pub fn reading(engine: &Engine, container: &str, harness: HarnessKind) -> Option<Reading> {
504    let command = ["node", "-e", harness.history_script()?, CODE_DIR, LEGACY_CODE_DIR];
505    Some(Reading {
506        state: engine.container_state(container),
507        start: engine.start_container(container),
508        wake: engine.unpause_container(container),
509        list: engine.exec_without_terminal(&Exec { container, command: &command }),
510    })
511}
512
513/// What reading takes, given what the engine said about the container.
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
515enum Steps {
516    /// The container is not there: the workspace has never run the profile, so it has had no
517    /// conversations with it, and nothing is made just to find that out.
518    Nothing,
519    /// It runs: the script is run in it.
520    List,
521    /// It is there and not running: it is started, and then the script is run.
522    StartThenList,
523    /// It is frozen: it is woken, and then the script is run. A frozen container is up, with every
524    /// tab in it still running and everything they wrote still in memory, so it is read as it is
525    /// and never started again — the engine refuses a start of a paused container, and a container
526    /// made afresh beside it would end every tab in it at once.
527    WakeThenList,
528}
529
530/// The steps for a container in `state`; `None` is a container the engine could not answer for.
531fn steps(state: Option<&ContainerState>) -> Steps {
532    match state {
533        None => Steps::Nothing,
534        Some(ContainerState::Running) => Steps::List,
535        Some(ContainerState::Paused) => Steps::WakeThenList,
536        Some(_) => Steps::StartThenList,
537    }
538}
539
540/// Reads `harness`'s conversations in the workspace out of `container`, newest first.
541///
542/// A container that is not there has had no conversations, and nothing is created for the
543/// question. One that is stopped is started and left running: the answer is read to open one of
544/// the conversations in it, and that is a tab in the same container.
545///
546/// This runs engine commands and waits for them, so it belongs on a background thread.
547///
548/// # Errors
549///
550/// When the engine cannot be started, or refuses to start the container or to run the script.
551pub fn read(engine: &Engine, container: &str, harness: HarnessKind) -> Result<Vec<Conversation>, EngineError> {
552    read_starting(engine, container, harness, &mut || {})
553}
554
555/// [`read`], calling `started` when the container was stopped and had to be started for it, so
556/// the caller can note a container QCode left running.
557///
558/// # Errors
559///
560/// When the engine cannot be started, or refuses to start the container or to run the script.
561pub fn read_starting(
562    engine: &Engine,
563    container: &str,
564    harness: HarnessKind,
565    started: &mut dyn FnMut(),
566) -> Result<Vec<Conversation>, EngineError> {
567    // A harness whose conversations QCode cannot list has none to show, and nothing is run to
568    // find that out: no container is started and no engine is asked.
569    let Some(reading) = reading(engine, container, harness) else { return Ok(Vec::new()) };
570    // Both engines answer a name they do not know with an error rather than with a state.
571    let state = capture(&reading.state).ok().map(|word| ContainerState::parse(&word));
572    match steps(state.as_ref()) {
573        Steps::Nothing => return Ok(Vec::new()),
574        Steps::List => {}
575        Steps::StartThenList => {
576            capture(&reading.start)?;
577            started();
578        }
579        // A frozen container is up: waking it is not starting it, so nothing is noted as a
580        // container QCode left running, and the conversations are read out of the container the
581        // tabs are in rather than one made beside it.
582        Steps::WakeThenList => {
583            capture(&reading.wake)?;
584        }
585    }
586    Ok(parse(&capture(&reading.list)?))
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::engine::EngineKind;
593
594    fn args(command: &EngineCommand) -> Vec<String> {
595        command.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
596    }
597
598    fn ids(found: &[Conversation]) -> Vec<&str> {
599        found.iter().map(|conversation| conversation.id.as_str()).collect()
600    }
601
602    fn script(harness: HarnessKind) -> &'static str {
603        harness.history_script().expect("a command-line harness has a script")
604    }
605
606    #[test]
607    fn reads_every_line_newest_first() {
608        let output = "2afe99eb-008a-4542-b160-1aa5b29bb95f\t1789746000000\tNamed by qcode\n\
609                      ses_f4acc7e75ffeEArYIV9UJooqnn\t1789746449823\tMy explicit title\n\
610                      01a0b53a-7904-7862-b8f4-02774d17df35\t1789746926000\t\n";
611        assert_eq!(
612            parse(output),
613            [
614                Conversation {
615                    id: "01a0b53a-7904-7862-b8f4-02774d17df35".to_owned(),
616                    title: None,
617                    used_ms: 1_789_746_926_000
618                },
619                Conversation {
620                    id: "ses_f4acc7e75ffeEArYIV9UJooqnn".to_owned(),
621                    title: Some("My explicit title".to_owned()),
622                    used_ms: 1_789_746_449_823
623                },
624                Conversation {
625                    id: "2afe99eb-008a-4542-b160-1aa5b29bb95f".to_owned(),
626                    title: Some("Named by qcode".to_owned()),
627                    used_ms: 1_789_746_000_000
628                },
629            ]
630        );
631    }
632
633    #[test]
634    fn nothing_printed_is_no_conversations() {
635        assert_eq!(parse(""), []);
636        assert_eq!(parse("\n\n"), []);
637    }
638
639    #[test]
640    fn a_line_that_is_not_a_conversation_is_left_out() {
641        let output = "just words\n\
642                      abc\tnot-a-time\ttitle\n\
643                      \t1789746000000\tno id\n\
644                      abc\t1789746000000.5\tfractional time\n\
645                      abc\n\
646                      good\t5\n\
647                      Error: something went wrong\n";
648        let found = parse(output);
649        assert_eq!(ids(&found), ["good"]);
650        assert_eq!(found[0].title, None, "a line without a title field has no title");
651    }
652
653    #[test]
654    fn an_id_that_could_be_read_as_anything_but_an_id_is_refused() {
655        let output = "--dangerously-skip-permissions\t9\tan option\n\
656                      -r\t9\ta short option\n\
657                      _hidden\t9\tstarts with a mark\n\
658                      two words\t9\twhite space\n\
659                      a;rm -rf /\t9\ta shell\n\
660                      ../../etc\t9\ta path\n\
661                      \u{0430}bc\t9\tnot ascii\n\
662                      $(id)\t9\ta substitution\n\
663                      ok-1_2\t1\tfine\n";
664        assert_eq!(ids(&parse(output)), ["ok-1_2"]);
665        assert!(!is_safe_id(&"a".repeat(ID_CHARS + 1)));
666        assert!(is_safe_id(&"a".repeat(ID_CHARS)));
667        assert!(!is_safe_id(""));
668    }
669
670    #[test]
671    fn a_title_is_one_tidy_line_of_bounded_length() {
672        let output = "a\t3\t  line one\u{1b}[31m\u{7}\r\u{85}line  two\u{2028}end  \n\
673                     b\t2\t\u{1}\u{2}   \u{7f}\n\
674                     c\t1\t";
675        let long = "x".repeat(TITLE_CHARS * 50);
676        let output = format!("{output}{long}\nd\t0\t{}\n", "é".repeat(TITLE_CHARS + 3));
677        let found = parse(&output);
678        assert_eq!(found[0].title.as_deref(), Some("line one [31m line two end"));
679        assert_eq!(found[1].title, None, "nothing but control characters is no title");
680        assert_eq!(found[2].title.as_deref().map(str::len), Some(TITLE_CHARS));
681        assert_eq!(found[3].title.as_deref().map(|title| title.chars().count()), Some(TITLE_CHARS));
682    }
683
684    #[test]
685    fn a_tab_inside_a_title_stays_part_of_the_title() {
686        assert_eq!(parse("a\t1\tone\ttwo")[0].title.as_deref(), Some("one two"));
687    }
688
689    #[test]
690    fn an_id_named_twice_is_kept_once_with_its_newest_use() {
691        let output = "same\t10\told name\nother\t20\t\nsame\t30\tnew name\nsame\t5\toldest\n";
692        let found = parse(output);
693        assert_eq!(ids(&found), ["same", "other"]);
694        assert_eq!(found[0].title.as_deref(), Some("new name"));
695        assert_eq!(found[0].used_ms, 30);
696    }
697
698    #[test]
699    fn equal_times_keep_one_order_every_time() {
700        assert_eq!(ids(&parse("b\t1\t\na\t1\t\nc\t1\t\n")), ["a", "b", "c"]);
701    }
702
703    #[test]
704    fn a_time_before_the_epoch_or_far_ahead_still_sorts() {
705        assert_eq!(ids(&parse("past\t-5\t\nfuture\t9223372036854775807\t\nnow\t0\t\n")), ["future", "now", "past"]);
706    }
707
708    #[test]
709    fn every_harness_has_a_script_that_reads_the_workspace_it_is_given() {
710        for harness in HarnessKind::TERMINAL {
711            let script = harness.history_script().expect("a command-line harness has a script");
712            assert!(script.starts_with(prelude!()), "{harness:?}");
713            assert!(script.len() > prelude!().len(), "{harness:?} has only the prelude");
714            assert!(script.contains("process.argv[1]"), "{harness:?}");
715            assert!(script.contains("process.argv[2]"), "{harness:?} knows only one name for the workspace");
716            assert!(script.contains("emit("), "{harness:?} never prints");
717            assert!(!script.contains("process.exit("), "{harness:?}: an early exit could cut the output short");
718        }
719    }
720
721    #[test]
722    fn each_script_looks_where_its_harness_writes() {
723        assert!(script(HarnessKind::ClaudeCode).contains("'.claude', 'projects'"));
724        assert!(script(HarnessKind::OpenCode).contains("'session', 'list', '--format', 'json'"));
725        assert!(script(HarnessKind::GeminiCli).contains("'projects.json'"));
726        assert!(script(HarnessKind::Codex).contains("'session_index.jsonl'"));
727    }
728
729    #[test]
730    fn a_window_harness_has_no_list_and_nothing_is_run_to_find_that_out() {
731        // The shape of what the agent in the window writes was never read, so QCode offers
732        // nothing rather than a list of conversations that might not open. Asking costs no
733        // engine command at all: a stopped container is not started for the question.
734        assert_eq!(HarnessKind::AntigravityIde.history_script(), None);
735        let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
736        assert_eq!(reading(&engine, "qcode-p-anti", HarnessKind::AntigravityIde), None);
737    }
738
739    #[test]
740    fn a_conversation_backup_takes_where_the_scripts_read_and_never_a_login() {
741        assert!(script(HarnessKind::ClaudeCode).contains("'.claude', 'projects'"));
742        assert!(script(HarnessKind::GeminiCli).contains("'.gemini'") && GEMINI_CLI.contains("'tmp'"));
743        assert!(script(HarnessKind::Codex).contains("'.codex'") && CODEX.contains("'sessions'"));
744        for harness in HarnessKind::ALL {
745            let paths = harness.conversation_paths();
746            assert!(!paths.is_empty(), "{harness:?}");
747            for path in paths {
748                assert!(!path.starts_with('/') && !path.starts_with('~') && !path.starts_with('-'), "{path}");
749                assert!(!path.split('/').any(|part| part == ".." || part == "." || part.is_empty()), "{path}");
750                for login in harness.record().identity {
751                    let inside = |outer: &str, inner: &str| inner == outer || inner.starts_with(&format!("{outer}/"));
752                    assert!(!inside(path, login) && !inside(login, path), "{harness:?}: {path} and {login}");
753                }
754            }
755            if let Some(database) = harness.conversation_database() {
756                for journal in ["", "-wal", "-shm"] {
757                    assert!(paths.contains(&format!("{database}{journal}").as_str()), "{harness:?}: {journal}");
758                }
759            }
760        }
761        assert_eq!(HarnessKind::OpenCode.conversation_database(), Some(".local/share/opencode/opencode.db"));
762    }
763
764    #[test]
765    fn the_script_runs_without_a_terminal_in_the_workspace_folder() {
766        let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
767        let reading = reading(&engine, "qcode-my-app-claude-sub", HarnessKind::ClaudeCode).expect("a script");
768        assert_eq!(
769            args(&reading.state),
770            ["container", "inspect", "--format", "{{.State.Status}}", "qcode-my-app-claude-sub"]
771        );
772        assert_eq!(args(&reading.start), ["start", "qcode-my-app-claude-sub"]);
773        let list = args(&reading.list);
774        assert_eq!(list[..4], ["exec", "qcode-my-app-claude-sub", "node", "-e"]);
775        assert_eq!(list[4], script(HarnessKind::ClaudeCode));
776        assert_eq!(list[5], CODE_DIR);
777        assert_eq!(list[6], LEGACY_CODE_DIR, "the name of before is handed over too, or old conversations vanish");
778        assert_eq!(list.len(), 7);
779        assert!(!list.iter().any(|arg| arg == "--tty" || arg == "--interactive" || arg == "--user"), "{list:?}");
780    }
781
782    #[test]
783    fn docker_reads_the_same_way() {
784        let engine = Engine::new(EngineKind::Docker, "/usr/bin/docker");
785        let reading = reading(&engine, "qcode-p-codex", HarnessKind::Codex).expect("a script");
786        assert_eq!(reading.list.program, std::path::Path::new("/usr/bin/docker"));
787        assert_eq!(args(&reading.list)[..2], ["exec", "qcode-p-codex"]);
788    }
789
790    #[test]
791    fn a_container_that_is_not_there_is_not_made_or_started() {
792        assert_eq!(steps(None), Steps::Nothing);
793    }
794
795    #[test]
796    fn a_running_container_is_only_read() {
797        assert_eq!(steps(Some(&ContainerState::Running)), Steps::List);
798    }
799
800    #[test]
801    fn a_stopped_container_is_started_before_it_is_read() {
802        for state in [ContainerState::Exited, ContainerState::Created, ContainerState::Unknown("stopping".to_owned())] {
803            assert_eq!(steps(Some(&state)), Steps::StartThenList, "{state:?}");
804        }
805    }
806
807    #[test]
808    fn a_frozen_container_is_woken_before_it_is_read_and_never_started() {
809        // A frozen container is up with every tab in it still holding what it held, so it is read as
810        // it is: the engine refuses a start of a paused container, and a container made afresh
811        // beside it would end every tab in it at once.
812        assert_eq!(steps(Some(&ContainerState::Paused)), Steps::WakeThenList);
813        let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
814        let reading = reading(&engine, "qcode-p-codex", HarnessKind::Codex).expect("a script");
815        assert_eq!(args(&reading.wake), ["unpause", "qcode-p-codex"], "the reading wakes the container");
816    }
817
818    #[test]
819    fn an_engine_that_is_not_there_has_no_container_to_read() {
820        // The state question fails before anything else, and a container the engine cannot
821        // answer for is one that is not there.
822        let engine = Engine::new(EngineKind::Podman, "/qcode/no/such/engine");
823        assert_eq!(read(&engine, "qcode-p-codex", HarnessKind::Codex).expect("nothing to read"), []);
824    }
825}