agent-sh 0.14.1 → 0.14.2

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.
Files changed (55) hide show
  1. package/dist/agent/agent-loop.d.ts +1 -1
  2. package/dist/agent/agent-loop.js +42 -31
  3. package/dist/agent/conversation-state.d.ts +3 -2
  4. package/dist/agent/conversation-state.js +20 -3
  5. package/dist/agent/events.d.ts +2 -0
  6. package/dist/agent/host-types.d.ts +3 -0
  7. package/dist/agent/index.js +2 -1
  8. package/dist/agent/subagent.d.ts +1 -1
  9. package/dist/agent/subagent.js +5 -1
  10. package/dist/agent/tool-protocol.d.ts +2 -2
  11. package/dist/agent/tool-protocol.js +5 -4
  12. package/dist/agent/tools/glob.d.ts +1 -1
  13. package/dist/agent/tools/glob.js +4 -2
  14. package/dist/agent/tools/grep.d.ts +1 -1
  15. package/dist/agent/tools/grep.js +4 -2
  16. package/dist/agent/tools/ls.d.ts +1 -1
  17. package/dist/agent/tools/ls.js +4 -2
  18. package/dist/agent/tools/read-file.d.ts +1 -1
  19. package/dist/agent/tools/read-file.js +30 -2
  20. package/dist/agent/types.d.ts +11 -1
  21. package/dist/agent/types.js +6 -1
  22. package/dist/cli/index.js +0 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/settings.d.ts +3 -0
  25. package/dist/core/settings.js +2 -2
  26. package/dist/shell/index.d.ts +6 -0
  27. package/dist/shell/index.js +10 -10
  28. package/dist/shell/shell.d.ts +4 -0
  29. package/dist/shell/shell.js +15 -29
  30. package/dist/shell/terminal.d.ts +33 -0
  31. package/dist/shell/terminal.js +62 -0
  32. package/examples/extensions/ash-scheme/index.ts +2170 -0
  33. package/examples/extensions/ash-scheme/package.json +11 -0
  34. package/examples/extensions/ash-scheme-render.ts +58 -0
  35. package/examples/extensions/ashi/README.md +36 -26
  36. package/examples/extensions/ashi/package.json +9 -1
  37. package/examples/extensions/ashi/src/capture.ts +1 -0
  38. package/examples/extensions/ashi/src/cli.ts +21 -7
  39. package/examples/extensions/ashi/src/compaction.ts +25 -96
  40. package/examples/extensions/ashi/src/components.ts +64 -166
  41. package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
  42. package/examples/extensions/ashi/src/display-config.ts +21 -22
  43. package/examples/extensions/ashi/src/frontend.ts +64 -65
  44. package/examples/extensions/ashi/src/hooks.ts +47 -63
  45. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  46. package/examples/extensions/ashi/src/schema.ts +407 -0
  47. package/examples/extensions/ashi/src/session-store.ts +55 -4
  48. package/examples/extensions/ashi/src/status-footer.ts +27 -6
  49. package/examples/extensions/ashi-compact-llm.ts +93 -0
  50. package/examples/extensions/claude-code-bridge/index.ts +2 -0
  51. package/examples/extensions/opencode-bridge/index.ts +3 -0
  52. package/examples/extensions/opencode-provider.ts +252 -0
  53. package/examples/extensions/pi-bridge/index.ts +1 -0
  54. package/package.json +12 -1
  55. package/examples/extensions/ashi/src/default-renderers.ts +0 -171
@@ -25,6 +25,7 @@ import {
25
25
  } from "./components.js";
26
26
  import type { ToolCallView, ToolResultView } from "./hooks.js";
27
27
  import { createToolHookResolver } from "./hooks.js";
28
+ import { loadGroupMaxVisible } from "./display-config.js";
28
29
 
29
30
  const GROUPABLE_KINDS = new Set(["read", "search"]);
30
31
  const TOOL_KIND: Record<string, string> = {
@@ -120,6 +121,10 @@ function detailFromArgs(argsJson: string | undefined): string {
120
121
  if (typeof args.path === "string") return relativize(args.path);
121
122
  if (typeof args.file_path === "string") return relativize(args.file_path);
122
123
  if (typeof args.query === "string") return `"${args.query}"`;
124
+ if (typeof args.source === "string") {
125
+ const compact = args.source.replace(/\s+/g, " ").trim();
126
+ return compact.length > 80 ? compact.slice(0, 77) + "…" : compact;
127
+ }
123
128
  } catch { /* fall through */ }
124
129
  return "";
125
130
  }
@@ -229,10 +234,22 @@ export function mountAshi(
229
234
  let activeAssistant: AssistantMessage | null = null;
230
235
  let activeThinking: ThinkingBlock | null = null;
231
236
  const activeTools = new Map<string, LiveToolEntry>();
232
- /** Per-batch state from agent:tool-batch — the group is created lazily on
233
- * the first member's tool-started so the chat insertion order is correct. */
234
- const batchGroups = new Map<string, { total: number; group: ToolGroup | null }>();
235
- let lastToolResult: ToolResultView | null = null;
237
+ const groupMaxVisible = loadGroupMaxVisible();
238
+
239
+ /** Find a same-kind ToolGroup at the chat tail. ThinkingBlocks are
240
+ * visually-neutral only when hidden visible thinking is a hard separator,
241
+ * so toggling thinking on splits previously-merged groups at iteration
242
+ * boundaries. */
243
+ const findMergeableGroup = (kind: string): ToolGroup | null => {
244
+ for (let i = chat.children.length - 1; i >= 0; i--) {
245
+ const c = chat.children[i]!;
246
+ if (c instanceof ToolGroup) return c.kind === kind ? c : null;
247
+ if (c instanceof ThinkingBlock && hideThinking) continue;
248
+ if (c instanceof AssistantMessage && !c.hasContent()) continue;
249
+ return null;
250
+ }
251
+ return null;
252
+ };
236
253
  let loader: Loader | null = null;
237
254
  let processing = false;
238
255
  const queuedQueries: string[] = [];
@@ -252,7 +269,7 @@ export function mountAshi(
252
269
  invalidate: () => tui.requestRender(),
253
270
  });
254
271
 
255
- const tools = createToolHookResolver(ctx, renderState);
272
+ const tools = createToolHookResolver(ctx);
256
273
 
257
274
  const renderUserMessage = (text: string): Component =>
258
275
  ctx.call("ashi:render-user-message", { text, ...renderState() }) as Component;
@@ -343,32 +360,18 @@ export function mountAshi(
343
360
  chat.addChild(renderAssistantFinal(text));
344
361
  }
345
362
  if (m.tool_calls) {
346
- const calls = m.tool_calls;
347
- let i = 0;
348
- while (i < calls.length) {
349
- const startName = calls[i]!.function?.name ?? "";
350
- const startKind = TOOL_KIND[startName];
351
- if (startKind && GROUPABLE_KINDS.has(startKind)) {
352
- let j = i;
353
- while (j < calls.length && TOOL_KIND[calls[j]!.function?.name ?? ""] === startKind) j++;
354
- const runLen = j - i;
355
- if (runLen > 1) {
356
- const group = new ToolGroup(startKind, runLen);
357
- chat.addChild(group);
358
- for (let k = i; k < j; k++) {
359
- const c = calls[k]!;
360
- const cid = c.id ?? "";
361
- const cname = c.function?.name ?? "tool";
362
- group.addCall(cid, cname, detailFromArgs(c.function?.arguments));
363
- if (cid) toolMap.set(cid, { kind: "group", group, name: cname });
364
- }
365
- i = j;
366
- continue;
367
- }
368
- }
369
- const tc = calls[i]!;
363
+ for (const tc of m.tool_calls) {
370
364
  const id = tc.id ?? "";
371
365
  const name = tc.function?.name ?? "tool";
366
+ const kind = TOOL_KIND[name];
367
+ if (kind && GROUPABLE_KINDS.has(kind)) {
368
+ const mergeable = findMergeableGroup(kind);
369
+ const group = mergeable
370
+ ?? (() => { const g = new ToolGroup(kind, groupMaxVisible); chat.addChild(g); return g; })();
371
+ group.addCall(id, name, detailFromArgs(tc.function?.arguments));
372
+ if (id) toolMap.set(id, { kind: "group", group, name });
373
+ continue;
374
+ }
372
375
  const pair = renderToolPair({
373
376
  toolCallId: id, name, title: name, kind: undefined,
374
377
  displayDetail: detailFromArgs(tc.function?.arguments),
@@ -377,8 +380,6 @@ export function mountAshi(
377
380
  chat.addChild(pair.call);
378
381
  chat.addChild(pair.result);
379
382
  if (id) toolMap.set(id, { kind: "pair", pair, name });
380
- lastToolResult = pair.result;
381
- i++;
382
383
  }
383
384
  }
384
385
  } else if (m.role === "tool") {
@@ -411,8 +412,6 @@ export function mountAshi(
411
412
  activeAssistant = null;
412
413
  activeThinking = null;
413
414
  activeTools.clear();
414
- batchGroups.clear();
415
- lastToolResult = null;
416
415
  chat.clear();
417
416
  const branch = getStore().current().getBranch();
418
417
  const toolMap = new Map<string, ReplayEntry>();
@@ -480,13 +479,6 @@ export function mountAshi(
480
479
  tui.requestRender();
481
480
  });
482
481
 
483
- bus.on("agent:tool-batch", (e) => {
484
- batchGroups.clear();
485
- for (const g of e.groups) {
486
- batchGroups.set(g.kind, { total: g.tools.length, group: null });
487
- }
488
- });
489
-
490
482
  bus.on("agent:tool-started", (e) => {
491
483
  finalizeThinking();
492
484
  if (activeAssistant) {
@@ -495,34 +487,29 @@ export function mountAshi(
495
487
  }
496
488
  const id = e.toolCallId ?? `${e.title}-${Date.now()}`;
497
489
  const title = e.title.split(":")[0]!.trim();
490
+ const lookupName = e.name ?? title;
498
491
  const detail = e.displayDetail || detailFromArgs(
499
492
  typeof e.rawInput === "string" ? e.rawInput : JSON.stringify(e.rawInput ?? {})
500
493
  );
501
494
 
502
495
  const kind = e.kind ?? "";
503
- const batchEntry = batchGroups.get(kind);
504
- const shouldGroup = !!batchEntry && batchEntry.total > 1 && GROUPABLE_KINDS.has(kind);
505
- if (shouldGroup) {
506
- if (!batchEntry!.group) {
507
- batchEntry!.group = new ToolGroup(kind, batchEntry!.total);
508
- chat.addChild(batchEntry!.group);
509
- }
510
- batchEntry!.group.addCall(id, title, detail);
511
- activeTools.set(id, { kind: "group", group: batchEntry!.group });
512
- // Grouped tools have no individual result body — Ctrl+O wouldn't have
513
- // anything to expand, so leave lastToolResult pointing at the prior tool.
496
+ if (GROUPABLE_KINDS.has(kind)) {
497
+ const mergeable = findMergeableGroup(kind);
498
+ const group = mergeable
499
+ ?? (() => { const g = new ToolGroup(kind, groupMaxVisible); chat.addChild(g); return g; })();
500
+ group.addCall(id, lookupName, detail);
501
+ activeTools.set(id, { kind: "group", group });
514
502
  tui.requestRender();
515
503
  return;
516
504
  }
517
505
 
518
506
  const pair = renderToolPair({
519
- toolCallId: id, name: title, title, kind: e.kind,
507
+ toolCallId: id, name: lookupName, title, kind: e.kind,
520
508
  displayDetail: detail, rawInput: e.rawInput,
521
509
  });
522
510
  activeTools.set(id, { kind: "pair", pair });
523
511
  chat.addChild(pair.call);
524
512
  chat.addChild(pair.result);
525
- lastToolResult = pair.result;
526
513
  tui.requestRender();
527
514
  });
528
515
 
@@ -743,14 +730,21 @@ export function mountAshi(
743
730
  // ── Keybindings ────────────────────────────────────────────────
744
731
  const toggleThinking = (): void => {
745
732
  hideThinking = !hideThinking;
746
- const walk = (node: Container): void => {
747
- for (const child of node.children) {
748
- if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
749
- else if (child instanceof Container) walk(child);
750
- }
751
- };
752
- walk(chat);
753
- tui.requestRender();
733
+ if (processing) {
734
+ // Mid-turn: in-place show/hide only. Past groups stay as they merged
735
+ // under the old flag; future tool calls in this turn respect the new
736
+ // flag via findMergeableGroup. Next idle rebuild reflows everything.
737
+ const walk = (node: Container): void => {
738
+ for (const child of node.children) {
739
+ if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
740
+ else if (child instanceof Container) walk(child);
741
+ }
742
+ };
743
+ walk(chat);
744
+ tui.requestRender();
745
+ return;
746
+ }
747
+ void rebuildChat();
754
748
  };
755
749
 
756
750
  tui.addInputListener((data) => {
@@ -807,10 +801,15 @@ export function mountAshi(
807
801
  return { consume: true };
808
802
  }
809
803
  if (matchesKey(data, "ctrl+o")) {
810
- if (lastToolResult) {
811
- lastToolResult.toggleExpanded();
812
- tui.requestRender();
813
- }
804
+ const toggle = (node: Container): void => {
805
+ for (const child of node.children) {
806
+ const fn = (child as unknown as { toggleExpanded?: () => void }).toggleExpanded;
807
+ if (typeof fn === "function") fn.call(child);
808
+ if (child instanceof Container) toggle(child);
809
+ }
810
+ };
811
+ toggle(chat);
812
+ tui.requestRender();
814
813
  return { consume: true };
815
814
  }
816
815
  return undefined;
@@ -1,12 +1,8 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
2
  import type { ExtensionContext } from "agent-sh/types";
3
- import {
4
- AssistantMessage,
5
- ThinkingBlock,
6
- ToolResultBody,
7
- UserMessage,
8
- } from "./components.js";
9
- import { entryFor, loadToolDisplayConfig, type ToolResultMode } from "./display-config.js";
3
+ import { AssistantMessage, ThinkingBlock, UserMessage } from "./components.js";
4
+ import { loadDisplayResolver, type ToolResultMode } from "./display-config.js";
5
+ import { isRenderModel, mountCall, mountResult, type RenderModel } from "./schema.js";
10
6
 
11
7
  export interface RenderState {
12
8
  state: Record<string, unknown>;
@@ -14,41 +10,19 @@ export interface RenderState {
14
10
  }
15
11
 
16
12
  export interface UserMessageArgs extends RenderState { text: string }
17
-
18
13
  export interface AssistantArgs extends RenderState { text: string }
19
-
20
14
  export interface ThinkingArgs extends RenderState { text: string; hidden: boolean }
21
15
 
22
- export interface ToolCallArgs extends RenderState {
23
- toolCallId: string;
24
- name: string;
25
- title: string;
26
- kind?: string;
27
- displayDetail?: string;
28
- rawInput?: unknown;
29
- }
30
-
31
- export interface ToolResultArgs extends RenderState {
32
- toolCallId: string;
33
- name: string;
34
- kind?: string;
35
- rawInput?: unknown;
36
- /** Resolved from ashi.display.{name} (or .default) in settings.json. */
37
- mode: ToolResultMode;
38
- previewLines: number;
39
- }
40
-
41
- /** Mutated by ashi when the tool completes. Renderers may ignore setStatus
42
- * if they encode status differently (e.g. a sigil in the call line). */
16
+ /** The contract ashi's frontend mutates on the call-side component. Schema
17
+ * renderers satisfy this via SchemaCallComponent — extension authors don't
18
+ * implement it directly. */
43
19
  export interface ToolCallView extends Component {
44
20
  setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
21
+ toggleExpanded?(): void;
45
22
  }
46
23
 
47
- /** Mutated by ashi as output streams in and when the tool completes.
48
- * setDiffRenderer is optional behavior renderers may no-op if they don't
49
- * show diffs. The renderer is called on each terminal-width change so diffs
50
- * reflow on resize. toggleExpanded flips the view's internal expansion state
51
- * (Ctrl+O). */
24
+ /** Result-side counterpart. setDiffRenderer receives the width-aware diff
25
+ * closure produced by the edit/write tool at finalize. */
52
26
  export interface ToolResultView extends Component {
53
27
  appendChunk(chunk: string): void;
54
28
  setDiffRenderer(fn: (width: number) => string[]): void;
@@ -56,12 +30,8 @@ export interface ToolResultView extends Component {
56
30
  toggleExpanded(): void;
57
31
  }
58
32
 
59
- const CALL_PREFIX = "ashi:render-tool-call:";
60
- const RESULT_PREFIX = "ashi:render-tool-result:";
33
+ const SCHEMA_PREFIX = "ashi:render-tool:";
61
34
 
62
- /** Register the default render-* handlers. Per-tool overrides are advised by
63
- * name (e.g. `ashi:render-tool-call:bash`); unknown tools fall back to
64
- * `:default`. */
65
35
  export function registerRenderDefaults(ctx: ExtensionContext): void {
66
36
  ctx.define("ashi:render-user-message", (args: UserMessageArgs): Component => {
67
37
  return new UserMessage(args.text);
@@ -85,54 +55,68 @@ export function registerRenderDefaults(ctx: ExtensionContext): void {
85
55
  tb.setHidden(args.hidden);
86
56
  return tb;
87
57
  });
58
+ }
88
59
 
89
- ctx.define(`${RESULT_PREFIX}default`, (args: ToolResultArgs): ToolResultView => {
90
- return new ToolResultBody(args.mode, args.previewLines);
91
- });
60
+ export interface ToolCallResolveArgs {
61
+ toolCallId: string;
62
+ name: string;
63
+ title: string;
64
+ kind?: string;
65
+ displayDetail?: string;
66
+ rawInput?: unknown;
67
+ }
68
+
69
+ export interface ToolResultResolveArgs {
70
+ toolCallId: string;
71
+ name: string;
72
+ kind?: string;
73
+ rawInput?: unknown;
92
74
  }
93
75
 
94
76
  export interface ToolHookResolver {
95
- call: (args: Omit<ToolCallArgs, "state" | "invalidate"> & Partial<RenderState>) => ToolCallView;
96
- result: (args: Omit<ToolResultArgs, "mode" | "previewLines" | "state" | "invalidate"> & Partial<RenderState>) => ToolResultView;
77
+ call: (args: ToolCallResolveArgs) => ToolCallView;
78
+ result: (args: ToolResultResolveArgs) => ToolResultView;
97
79
  modeFor: (name: string) => { mode: ToolResultMode; previewLines: number };
98
80
  }
99
81
 
100
- /** Resolves :{name} :default for tool render hooks and looks up each tool's
101
- * display mode from ashi.display. Cache the registered-handler set; callers
102
- * can `refresh()` after extensions register new tool-specific renderers. */
82
+ /** Resolves a tool name to a schema RenderModel :{name} first, then :default.
83
+ * Cache the registered-handler set; callers can `refresh()` after extensions
84
+ * register new tool-specific renderers. */
103
85
  export function createToolHookResolver(
104
86
  ctx: ExtensionContext,
105
- renderState: () => RenderState,
106
87
  ): ToolHookResolver & { refresh: () => void } {
107
- const config = loadToolDisplayConfig();
88
+ const resolver = loadDisplayResolver();
108
89
  let registered = new Set(ctx.list());
109
90
 
110
- const pick = (prefix: string, name: string): string => {
111
- const specific = `${prefix}${name}`;
112
- return registered.has(specific) ? specific : `${prefix}default`;
91
+ const schemaModel = (name: string): RenderModel<unknown> => {
92
+ for (const candidate of [`${SCHEMA_PREFIX}${name}`, `${SCHEMA_PREFIX}default`]) {
93
+ if (!registered.has(candidate)) continue;
94
+ const v = ctx.call(candidate, {}) as unknown;
95
+ if (isRenderModel(v)) return v;
96
+ }
97
+ throw new Error(`no render model for tool "${name}" and no ${SCHEMA_PREFIX}default registered`);
113
98
  };
114
99
 
100
+ // SchemaResultComponent.render(width) corrects env.width on the first frame.
101
+ const initialWidth = (): number => process.stdout.columns ?? 80;
102
+
115
103
  return {
116
104
  refresh(): void {
117
105
  registered = new Set(ctx.list());
118
106
  },
119
107
  modeFor(name: string) {
120
- const e = entryFor(config, name);
108
+ const e = resolver.resolve(name, schemaModel(name).display);
121
109
  return { mode: e.result, previewLines: e.previewLines };
122
110
  },
123
111
  call(args) {
124
- const handler = pick(CALL_PREFIX, args.name);
125
- return ctx.call(handler, { ...renderState(), ...args }) as ToolCallView;
112
+ const { mode, previewLines } = this.modeFor(args.name);
113
+ return mountCall(schemaModel(args.name), args,
114
+ { width: initialWidth(), mode, previewLines }) as ToolCallView;
126
115
  },
127
116
  result(args) {
128
117
  const { mode, previewLines } = this.modeFor(args.name);
129
- const handler = pick(RESULT_PREFIX, args.name);
130
- return ctx.call(handler, {
131
- ...renderState(),
132
- ...args,
133
- mode,
134
- previewLines,
135
- }) as ToolResultView;
118
+ return mountResult(schemaModel(args.name), { ...args, title: args.name },
119
+ { width: initialWidth(), mode, previewLines }) as ToolResultView;
136
120
  },
137
121
  };
138
122
  }
@@ -13,21 +13,62 @@ export interface SessionInfo {
13
13
  }
14
14
 
15
15
  /** Many sessions per cwd. Each is one .jsonl file under `dir/`. Constructor
16
- * always opens a fresh session; /resume callers can `openSession(id)` to
17
- * swap the current store to a past session file. */
16
+ * always opens a fresh session unless `opts.resumeSessionId` is given and
17
+ * points to an existing session file. /resume callers can `openSession(id)`
18
+ * to swap the current store to a past session file. */
18
19
  export class MultiSessionStore {
19
20
  private dir: string;
20
21
  private cwd: string;
21
22
  private currentStore: SessionStore;
22
23
 
23
- constructor(dir: string, cwd: string) {
24
+ constructor(dir: string, cwd: string, opts?: { resumeSessionId?: string }) {
24
25
  this.dir = dir;
25
26
  this.cwd = cwd;
26
27
  fs.mkdirSync(dir, { recursive: true });
27
28
  this.migrateLegacy();
29
+ if (opts?.resumeSessionId) {
30
+ const filePath = this.sessionFile(opts.resumeSessionId);
31
+ if (fs.existsSync(filePath)) {
32
+ this.currentStore = new SessionStore(filePath);
33
+ return;
34
+ }
35
+ }
28
36
  this.currentStore = this.createFreshSession();
29
37
  }
30
38
 
39
+ markLastSession(): void {
40
+ const lastFile = path.join(this.dir, ".last-session");
41
+ fs.writeFileSync(lastFile, this.currentStore.id);
42
+ }
43
+
44
+ static readLastSessionId(dir: string, opts?: { fallbackToLatest?: boolean }): string | undefined {
45
+ const lastFile = path.join(dir, ".last-session");
46
+ try {
47
+ const id = fs.readFileSync(lastFile, "utf-8").trim();
48
+ if (id && fs.existsSync(path.join(dir, `${id}.jsonl`))) return id;
49
+ } catch { /* no .last-session file yet */ }
50
+
51
+ if (opts?.fallbackToLatest) {
52
+ let best: { id: string; createdAt: number } | undefined;
53
+ let names: string[];
54
+ try { names = fs.readdirSync(dir); } catch { return undefined; }
55
+ for (const name of names) {
56
+ if (!name.endsWith(".jsonl")) continue;
57
+ const id = name.slice(0, -".jsonl".length);
58
+ try {
59
+ const raw = fs.readFileSync(path.join(dir, `${id}.jsonl.meta`), "utf-8");
60
+ const meta = JSON.parse(raw) as { createdAt?: number };
61
+ if (typeof meta.createdAt === "number" && (!best || meta.createdAt > best.createdAt)) {
62
+ best = { id, createdAt: meta.createdAt };
63
+ }
64
+ } catch { /* skip unreadable meta */ }
65
+ }
66
+ if (best) return best.id;
67
+ }
68
+
69
+ return undefined;
70
+ }
71
+
31
72
  /** One-time import from the previous storage format (sessions stored as
32
73
  * directories with tree.jsonl + snapshots/). Each old session is replayed
33
74
  * from its most recent snapshot into a new flat `.jsonl` file, then the