@multiclawcli/dashboard 0.9.0 → 0.10.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/dist/tui/App.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import type { EventBus, MultiClawConfig } from "@multiclawcli/core";
2
2
  import { AgentSessionManager } from "@multiclawcli/core";
3
+ /**
4
+ * How the console should choose its session on launch.
5
+ *
6
+ * Absent means the default: continue the most recent session, or start one when
7
+ * the workspace has none. `new` always starts fresh. `resume` opens the named
8
+ * session, or — with no id — the most recent one with the picker already open,
9
+ * so `multiclaw -r` lands on the list the way `claude -r` does.
10
+ */
11
+ export type InitialSession = {
12
+ kind: "new";
13
+ } | {
14
+ kind: "resume";
15
+ id?: string;
16
+ };
3
17
  export interface AppProps {
4
18
  config: MultiClawConfig;
5
19
  /**
@@ -11,5 +25,7 @@ export interface AppProps {
11
25
  sessions?: AgentSessionManager;
12
26
  /** Gives the terminal host an awaitable cleanup barrier, including render failures. */
13
27
  onCleanupReady?: (cleanup: () => Promise<void>) => void;
28
+ /** How to choose the session on launch; defaults to continuing the latest. */
29
+ initialSession?: InitialSession;
14
30
  }
15
- export declare function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }: AppProps): import("react").JSX.Element;
31
+ export declare function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady, initialSession, }: AppProps): import("react").JSX.Element;
@@ -19,3 +19,13 @@ export declare function writeJsonFile(target: string, json: string): Promise<voi
19
19
  * the temporary file behind and the previous — older — file in place.
20
20
  */
21
21
  export declare function pendingWrite(target: string): Promise<void>;
22
+ /**
23
+ * Append newline-terminated lines to `target`, serialized per path on the same
24
+ * chain as {@link writeJsonFile}.
25
+ *
26
+ * Unlike the rewrite path this does not go through a temp file: an append is the
27
+ * cheap O(1) write a JSONL log wants, and a crash mid-append can only tear the
28
+ * *last* line, which every line-oriented reader here already skips. Sharing the
29
+ * `writes` chain keeps a `pendingWrite` on the same path able to await it.
30
+ */
31
+ export declare function appendLines(target: string, lines: readonly string[]): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import type { SessionMeta } from "../sessions";
1
2
  import { type TurnView } from "../state";
2
3
  interface HelpGroup {
3
4
  title: string;
@@ -61,4 +62,22 @@ export declare function HistoryOverlay({ turns, selected, width, maxRows, }: {
61
62
  /** Rows available in the body. The list is windowed against this. */
62
63
  maxRows: number;
63
64
  }): import("react").JSX.Element;
65
+ /**
66
+ * The session picker: every session in the workspace, newest activity first.
67
+ *
68
+ * The level above {@link HistoryOverlay}. Where the history list browses the
69
+ * turns *within* a session, this switches between whole sessions — the thing a
70
+ * restart, a `/new`, or opening the console again all create. The one the
71
+ * console is attached to is marked, because "which am I on" is the first
72
+ * question the list has to answer.
73
+ */
74
+ export declare function SessionsOverlay({ sessions, selected, currentId, width, maxRows, now, }: {
75
+ sessions: SessionMeta[];
76
+ selected: number;
77
+ currentId: string | undefined;
78
+ width: number;
79
+ /** Rows available in the body. The list is windowed against this. */
80
+ maxRows: number;
81
+ now: number;
82
+ }): import("react").JSX.Element;
64
83
  export {};
@@ -43,6 +43,14 @@ export declare function wrapLine(line: string, width: number): string[];
43
43
  * scan a stack of durations only if they share a shape.
44
44
  */
45
45
  export declare function formatDuration(ms: number): string;
46
+ /**
47
+ * A coarse "how long ago", for the session list.
48
+ *
49
+ * Deliberately low-resolution — the picker wants "which one did I touch last",
50
+ * not a precise clock. Future timestamps (a clock that moved backwards) read as
51
+ * "just now" rather than a negative age.
52
+ */
53
+ export declare function formatAgo(timestamp: number, now: number): string;
46
54
  /** Stage markers read as an ordered sequence at a glance; past twenty, fall back. */
47
55
  export declare function circled(n: number): string;
48
56
  /** Pad to a fixed column so sibling rows line up. */
@@ -1,14 +1,12 @@
1
1
  import { type TurnView } from "./state";
2
- /** Directory, under a workspace's `.multiclaw/`, that holds console state. */
3
- export declare const CONSOLE_DIR = "console";
4
- /** The finished-turn record the console keeps across sessions. */
5
- export declare const HISTORY_FILE = "history.json";
2
+ /** The finished-turn log a session keeps, one JSON turn per line. */
3
+ export declare const TURNS_FILE = "turns.jsonl";
6
4
  /** Where exported turn markdown files are written by default. */
7
5
  export declare const EXPORT_DIR = "exports";
8
- /** Where the console keeps its turn history for a workspace. */
9
- export declare function historyPath(workDir: string): string;
6
+ /** Where a session keeps its finished turns. */
7
+ export declare function turnsPath(workDir: string, sessionId: string): string;
10
8
  /**
11
- * Read the workspace's turn history.
9
+ * Read a session's turn history.
12
10
  *
13
11
  * Synchronous on purpose: it runs once, inside the reducer's lazy initialiser,
14
12
  * before the first frame. An async load would race the user's first keystroke
@@ -16,18 +14,21 @@ export declare function historyPath(workDir: string): string;
16
14
  *
17
15
  * Never throws. A missing directory, malformed JSON, or a value of the wrong
18
16
  * shape all mean "no history": the console can always start from empty, and
19
- * crashing on startup because a side file got corrupted would be far worse.
17
+ * crashing on startup because a side file got corrupted would be far worse. The
18
+ * log is line-oriented, so a single torn line (a crash mid-append) is skipped
19
+ * rather than costing the whole session's history.
20
20
  */
21
- export declare function loadHistory(workDir: string): TurnView[];
21
+ export declare function loadTurns(workDir: string, sessionId: string): TurnView[];
22
22
  /**
23
- * Write the workspace's turn history.
23
+ * Append finished turns to a session's log, one JSON object per line.
24
24
  *
25
- * Atomic and serialized per path (see {@link writeJsonFile}), because a crash
26
- * mid-write must leave the previous history readable rather than half a
27
- * document: `loadHistory` can only drop a file it cannot parse, so a torn write
28
- * would cost the user *all* of their history rather than the last one.
25
+ * Append-only and serialized per path (see {@link appendLines}): each settled
26
+ * turn is an O(1) write rather than a rewrite of the whole history, and a crash
27
+ * mid-append can only tear the last line, which {@link loadTurns} skips. Callers
28
+ * pass only the turns not yet persisted, tracked by the highest turn number
29
+ * already written.
29
30
  */
30
- export declare function saveHistory(workDir: string, turns: readonly TurnView[]): Promise<void>;
31
- export declare function exportPath(workDir: string, turn: number): string;
31
+ export declare function appendTurns(workDir: string, sessionId: string, turns: readonly TurnView[]): Promise<void>;
32
+ export declare function exportPath(workDir: string, sessionId: string, turn: number): string;
32
33
  export declare function exportTurn(turn: TurnView, filePath: string): Promise<void>;
33
34
  export declare function formatTurnMarkdown(turn: TurnView): string;
@@ -1,11 +1,14 @@
1
1
  import { type EventBus, type MultiClawConfig } from "@multiclawcli/core";
2
- export type { AppProps } from "./App";
2
+ import { type InitialSession } from "./App";
3
+ export type { AppProps, InitialSession } from "./App";
3
4
  export * from "./layout";
4
5
  export * from "./state";
5
6
  export interface TuiOptions {
6
7
  config: MultiClawConfig;
7
8
  /** Attach per-turn reporters (e.g. a file log writer) here. */
8
9
  onTurnStart?: (eventBus: EventBus, logDir: string) => void | (() => void | Promise<void>);
10
+ /** How to choose the session on launch; defaults to continuing the latest. */
11
+ initialSession?: InitialSession;
9
12
  }
10
13
  /**
11
14
  * Render the interactive team console.
@@ -5,10 +5,10 @@ import { type AgentView, type TuiStageInfo, type TuiState, type TurnView } from
5
5
  * Live agent sessions are child processes, and a pending approval is an RPC in
6
6
  * flight to one of them — neither survives the process that owns them. So this
7
7
  * file deliberately does not try to *continue* a turn. It records the boundary
8
- * instead: which turn was running, what the user had typed but not run, where
9
- * the current task began, and how many decisions were waiting. A restart can
10
- * then say what happened, show the interrupted turn as a turn, and hand the
11
- * queued input back — without pretending a dead agent is still working.
8
+ * instead: which turn was running, what the user had typed but not run, and how
9
+ * many decisions were waiting. A restart can then say what happened, show the
10
+ * interrupted turn as a turn, and hand the queued input back — without
11
+ * pretending a dead agent is still working.
12
12
  */
13
13
  export declare const SESSION_FILE = "session.json";
14
14
  /** Bumped when the on-disk shape changes in a way an older console cannot read. */
@@ -48,29 +48,21 @@ export interface JournalTurn {
48
48
  }
49
49
  export interface SessionJournal {
50
50
  version: number;
51
- /**
52
- * Turn number the current task began at.
53
- *
54
- * `/task` closes the previous task's context, and that boundary has to outlive
55
- * the process: without it a restart would hand the leader every turn ever run
56
- * in the workspace, including the ones the user explicitly closed.
57
- */
58
- taskStart?: number;
59
51
  /** Follow-ups typed but never run, in the order they were typed. */
60
52
  queue?: string[];
61
53
  inFlight?: JournalTurn;
62
54
  }
63
- /** Where the console keeps its session journal for a workspace. */
64
- export declare function sessionPath(workDir: string): string;
55
+ /** Where a session keeps its journal. */
56
+ export declare function sessionPath(workDir: string, sessionId: string): string;
65
57
  /**
66
- * Read the workspace's session journal.
58
+ * Read a session's journal.
67
59
  *
68
- * Never throws, for the same reason `loadHistory` does not: a corrupted side
69
- * file means "no journal", and a console that refuses to start over it would be
70
- * far worse than one that starts with an empty past. A journal written by a
71
- * newer console (unknown `version`) is ignored rather than guessed at.
60
+ * Never throws, for the same reason `loadTurns` does not: a corrupted side file
61
+ * means "no journal", and a console that refuses to start over it would be far
62
+ * worse than one that starts with an empty past. A journal written by a newer
63
+ * console (unknown `version`) is ignored rather than guessed at.
72
64
  */
73
- export declare function loadJournal(workDir: string): SessionJournal | undefined;
65
+ export declare function loadJournal(workDir: string, sessionId: string): SessionJournal | undefined;
74
66
  /**
75
67
  * Write the workspace's session journal.
76
68
  *
@@ -78,12 +70,12 @@ export declare function loadJournal(workDir: string): SessionJournal | undefined
78
70
  * to survive a process that dies mid-write, so the write itself must never be
79
71
  * the thing that leaves a torn file behind.
80
72
  *
81
- * An empty journal does not create the file. A console that was opened and quit
82
- * without doing anything should not leave `.multiclaw/console/` behind — but an
83
- * *existing* journal is still rewritten when it empties, because "nothing is in
84
- * flight" is exactly what the next start needs to read.
73
+ * An empty journal does not create the file. A session that was opened and quit
74
+ * without doing anything should not leave a journal behind — but an *existing*
75
+ * journal is still rewritten when it empties, because "nothing is in flight" is
76
+ * exactly what the next start needs to read.
85
77
  */
86
- export declare function saveJournal(workDir: string, journal: SessionJournal): Promise<void>;
78
+ export declare function saveJournal(workDir: string, sessionId: string, journal: SessionJournal): Promise<void>;
87
79
  /**
88
80
  * The journal entry for a turn that is running right now.
89
81
  *
@@ -115,20 +107,25 @@ export declare function interruptedTurn(entry: JournalTurn): TurnView;
115
107
  export interface RestoredConsole {
116
108
  history: TurnView[];
117
109
  queue: string[];
118
- taskStart: number;
110
+ /**
111
+ * Highest turn number already on disk in `turns.jsonl` — the watermark the
112
+ * console appends past. Excludes a journal-recovered interrupted turn, which
113
+ * lives only in the journal until the next save writes it to the log.
114
+ */
115
+ persistedTurn: number;
119
116
  /** The last turn ended by asking the user something, so the console reopens ready for it. */
120
117
  awaitingAnswer: boolean;
121
118
  /** One line for the notice bar, when the previous session left work behind. */
122
119
  notice?: string;
123
120
  }
124
121
  /**
125
- * Everything the console should open with, read once at startup.
122
+ * Everything the console should open a session with, read once at startup.
126
123
  *
127
124
  * The interrupted turn is appended to the restored history rather than replacing
128
- * it: the turns before it still happened. It is skipped when history already
129
- * holds that turn number, because a restored turn is written back to
130
- * `history.json` on the very next save — a second restart must not add it twice.
131
- * A journal *behind* the last recorded turn is stale (the history was written by
132
- * a later session) and is dropped for the same reason.
125
+ * it: the turns before it still happened. It is skipped when the log already
126
+ * holds that turn number, because a restored turn is written to the log on the
127
+ * very next save — a second restart must not add it twice. A journal *behind*
128
+ * the last recorded turn is stale (the log was written by a later session) and
129
+ * is dropped for the same reason.
133
130
  */
134
- export declare function restoreConsole(workDir: string): RestoredConsole;
131
+ export declare function restoreConsole(workDir: string, sessionId: string): RestoredConsole;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Sessions: the unit of history and resumption.
3
+ *
4
+ * A session is one continuous line of work — a sequence of turns, each fanning
5
+ * out to the same team of agents. It is what Claude Code / Codex call a session:
6
+ * an independent, listable, resumable, deletable record. Everything a session
7
+ * owns lives under its own directory (`turns.jsonl`, `session.json`, exports),
8
+ * and a small `index.json` holds just the summaries the picker needs so it never
9
+ * has to open a session to list one.
10
+ */
11
+ export declare const SESSIONS_DIR = "sessions";
12
+ /** The summary index, read by the picker; one entry per session. */
13
+ export declare const INDEX_FILE = "index.json";
14
+ /** How long a session title may grow before it is truncated for the index. */
15
+ export declare const MAX_TITLE_CHARS = 80;
16
+ export interface SessionMeta {
17
+ id: string;
18
+ /** First turn's requirement, truncated. Empty until the session runs a turn. */
19
+ title: string;
20
+ createdAt: number;
21
+ lastActiveAt: number;
22
+ /** Highest turn number the session has recorded. */
23
+ turnCount: number;
24
+ }
25
+ export declare function sessionsRoot(workDir: string): string;
26
+ export declare function sessionDir(workDir: string, id: string): string;
27
+ /**
28
+ * Read the session index, newest-first by last activity.
29
+ *
30
+ * Never throws, like `loadTurns`: a missing or corrupt index means "no sessions
31
+ * the picker can show", which is always a safe place to start from. A single bad
32
+ * entry is dropped rather than losing the whole list.
33
+ */
34
+ export declare function listSessions(workDir: string): SessionMeta[];
35
+ /** The most recently active session, or undefined when there is none. */
36
+ export declare function latestSession(workDir: string): SessionMeta | undefined;
37
+ /**
38
+ * Register a new, empty session and return its metadata.
39
+ *
40
+ * The directory is not created here — `appendTurns` and `saveJournal` both
41
+ * `mkdir` lazily, so a session that is created and never run leaves nothing but
42
+ * an index entry behind.
43
+ */
44
+ export declare function createSession(workDir: string): SessionMeta;
45
+ /** Remove a session's directory and its index entry. Idempotent. */
46
+ export declare function deleteSession(workDir: string, id: string): void;
47
+ /** Update a session's summary in the index. `lastActiveAt` defaults to now. */
48
+ export declare function touchSession(workDir: string, id: string, patch: Partial<Pick<SessionMeta, "title" | "turnCount" | "lastActiveAt">>): void;
@@ -210,6 +210,27 @@ export type TuiAction = {
210
210
  /** Flip whether tool detail is shown in the stream. */
211
211
  | {
212
212
  type: "verbose:toggle";
213
+ }
214
+ /**
215
+ * Start a fresh session in place: empty history, turn counter back to zero,
216
+ * roster reseeded. Carries the seeded agents and leader ids the same way
217
+ * `turn:start` does, so no module-level object is shared into live state.
218
+ */
219
+ | {
220
+ type: "session:reset";
221
+ agents: Record<string, AgentView>;
222
+ leaderIds: string[];
223
+ }
224
+ /**
225
+ * Switch to an existing session: like `session:reset`, but seeded with that
226
+ * session's finished turns so its history is on screen and the turn counter
227
+ * continues from where it left off.
228
+ */
229
+ | {
230
+ type: "session:load";
231
+ agents: Record<string, AgentView>;
232
+ leaderIds: string[];
233
+ history: TurnView[];
213
234
  };
214
235
  export declare function stripAnsi(text: string): string;
215
236
  /** Append a chunk, keeping only the last {@link MAX_LOG_CHARS} characters. */
package/dist/tui.js CHANGED
@@ -4,7 +4,7 @@ import { render } from "ink";
4
4
  import { createElement } from "react";
5
5
 
6
6
  // src/tui/App.tsx
7
- import * as path4 from "path";
7
+ import * as path5 from "path";
8
8
  import {
9
9
  AgentSessionManager,
10
10
  describeResumePlan,
@@ -38,6 +38,22 @@ function pendingWrite(target) {
38
38
  return (writes.get(target) ?? Promise.resolve()).catch(() => {
39
39
  });
40
40
  }
41
+ function appendLines(target, lines) {
42
+ if (lines.length === 0) return pendingWrite(target);
43
+ const payload = `${lines.join("\n")}
44
+ `;
45
+ const write = (writes.get(target) ?? Promise.resolve()).catch(() => {
46
+ }).then(async () => {
47
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
48
+ await fs.promises.appendFile(target, payload, "utf-8");
49
+ });
50
+ writes.set(target, write);
51
+ void write.finally(() => {
52
+ if (writes.get(target) === write) writes.delete(target);
53
+ }).catch(() => {
54
+ });
55
+ return write;
56
+ }
41
57
 
42
58
  // src/tui/commands.ts
43
59
  var COMMANDS = [
@@ -55,6 +71,11 @@ var COMMANDS = [
55
71
  description: "Browse finished turns, or continue one",
56
72
  availability: "always"
57
73
  },
74
+ {
75
+ name: "sessions",
76
+ description: "Switch between, or delete, saved sessions",
77
+ availability: "always"
78
+ },
58
79
  {
59
80
  name: "verbose",
60
81
  description: "Toggle tool detail in the stream",
@@ -101,9 +122,9 @@ var COMMANDS = [
101
122
  availability: "running"
102
123
  },
103
124
  {
104
- name: "task",
125
+ name: "new",
105
126
  args: "<requirement>",
106
- description: "Start a new task with fresh agent sessions",
127
+ description: "Start a new session with fresh agent sessions",
107
128
  availability: "idle"
108
129
  },
109
130
  {
@@ -326,6 +347,15 @@ function formatDuration(ms) {
326
347
  const hours = Math.floor(minutes / 60);
327
348
  return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
328
349
  }
350
+ function formatAgo(timestamp, now) {
351
+ const seconds = Math.max(0, Math.floor((now - timestamp) / 1e3));
352
+ if (seconds < 60) return "just now";
353
+ const minutes = Math.floor(seconds / 60);
354
+ if (minutes < 60) return `${minutes}m ago`;
355
+ const hours = Math.floor(minutes / 60);
356
+ if (hours < 24) return `${hours}h ago`;
357
+ return `${Math.floor(hours / 24)}d ago`;
358
+ }
329
359
  var CIRCLED = "\u2460\u2461\u2462\u2463\u2464\u2465\u2466\u2467\u2468\u2469\u246A\u246B\u246C\u246D\u246E\u246F\u2470\u2471\u2472\u2473";
330
360
  function circled(n) {
331
361
  return CIRCLED[n - 1] ?? `(${n})`;
@@ -1045,8 +1075,42 @@ ${action.text}
1045
1075
  return state.view ? { ...state, view: void 0 } : state;
1046
1076
  case "verbose:toggle":
1047
1077
  return { ...state, verbose: !state.verbose };
1078
+ case "session:reset":
1079
+ return seededState(state, action.agents, action.leaderIds, []);
1080
+ case "session:load":
1081
+ return seededState(state, action.agents, action.leaderIds, action.history);
1048
1082
  }
1049
1083
  }
1084
+ function seededState(state, agents, leaderIds, history) {
1085
+ const ids = Object.keys(agents);
1086
+ return {
1087
+ phase: "idle",
1088
+ name: state.name,
1089
+ turn: history[history.length - 1]?.turn ?? 0,
1090
+ requirement: void 0,
1091
+ effectiveRequirement: void 0,
1092
+ startedAt: void 0,
1093
+ endedAt: void 0,
1094
+ cancelled: void 0,
1095
+ interrupted: void 0,
1096
+ success: void 0,
1097
+ acceptance: void 0,
1098
+ totalDuration: void 0,
1099
+ totalCostUsd: void 0,
1100
+ reason: void 0,
1101
+ warning: void 0,
1102
+ reply: void 0,
1103
+ stages: [],
1104
+ leaderIds,
1105
+ agents,
1106
+ focusId: ids[0],
1107
+ autoFollow: true,
1108
+ scroll: 0,
1109
+ history,
1110
+ view: void 0,
1111
+ verbose: state.verbose
1112
+ };
1113
+ }
1050
1114
  function stageStatus(stage, agents) {
1051
1115
  if (stage.outcome !== void 0) return stage.outcome;
1052
1116
  const statuses = stage.agents.map((a) => agents[a.id]?.status ?? "pending");
@@ -1497,6 +1561,54 @@ function HistoryOverlay({
1497
1561
  /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: `\u2191\u2193 select \xB7 enter open \xB7 c continue \xB7 esc close${range}` }) })
1498
1562
  ] });
1499
1563
  }
1564
+ function SessionsOverlay({
1565
+ sessions,
1566
+ selected,
1567
+ currentId,
1568
+ width,
1569
+ maxRows,
1570
+ now
1571
+ }) {
1572
+ const capacity = historyCapacity(maxRows);
1573
+ const start = listWindowStart(selected, sessions.length, capacity);
1574
+ const visible = sessions.slice(start, start + capacity);
1575
+ const range = sessions.length > capacity ? ` \xB7 showing ${start + 1}\u2013${start + visible.length} of ${sessions.length}` : "";
1576
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
1577
+ /* @__PURE__ */ jsxs2(Box2, { justifyContent: "space-between", children: [
1578
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "SESSIONS" }),
1579
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1580
+ sessions.length,
1581
+ " total",
1582
+ range
1583
+ ] })
1584
+ ] }),
1585
+ sessions.length === 0 ? /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "No sessions yet." }) }) : /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, children: visible.map((session, offset) => {
1586
+ const index = start + offset;
1587
+ const focused = index === selected;
1588
+ const current = session.id === currentId;
1589
+ const count = session.turnCount;
1590
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [
1591
+ /* @__PURE__ */ jsxs2(Box2, { children: [
1592
+ /* @__PURE__ */ jsx2(Text2, { color: focused ? COLOR.accent : void 0, children: focused ? `${GLYPH.caret} ` : " " }),
1593
+ /* @__PURE__ */ jsxs2(Text2, { bold: focused, color: focused ? COLOR.accent : void 0, children: [
1594
+ current ? "\u25CF " : "",
1595
+ truncate(session.title || "(untitled session)", Math.max(8, width - 30))
1596
+ ] })
1597
+ ] }),
1598
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1599
+ " ",
1600
+ count,
1601
+ " turn",
1602
+ count === 1 ? "" : "s",
1603
+ " \xB7 ",
1604
+ formatAgo(session.lastActiveAt, now),
1605
+ current ? " \xB7 current" : ""
1606
+ ] })
1607
+ ] }, session.id);
1608
+ }) }),
1609
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" }) })
1610
+ ] });
1611
+ }
1500
1612
 
1501
1613
  // src/tui/components/interaction.tsx
1502
1614
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
@@ -1916,13 +2028,93 @@ function CommandMenu({ rows, selected }) {
1916
2028
  }
1917
2029
 
1918
2030
  // src/tui/history.ts
2031
+ import * as fs3 from "fs";
2032
+ import * as path3 from "path";
2033
+
2034
+ // src/tui/sessions.ts
2035
+ import { randomUUID } from "crypto";
1919
2036
  import * as fs2 from "fs";
1920
2037
  import * as path2 from "path";
1921
- var CONSOLE_DIR = "console";
1922
- var HISTORY_FILE = "history.json";
2038
+ var SESSIONS_DIR = "sessions";
2039
+ var INDEX_FILE = "index.json";
2040
+ var MAX_TITLE_CHARS = 80;
2041
+ function sessionsRoot(workDir) {
2042
+ return path2.join(workDir, ".multiclaw", SESSIONS_DIR);
2043
+ }
2044
+ function sessionDir(workDir, id) {
2045
+ return path2.join(sessionsRoot(workDir), id);
2046
+ }
2047
+ function indexPath(workDir) {
2048
+ return path2.join(sessionsRoot(workDir), INDEX_FILE);
2049
+ }
2050
+ function isSessionMeta(value) {
2051
+ if (typeof value !== "object" || value === null) return false;
2052
+ const meta = value;
2053
+ return typeof meta.id === "string" && typeof meta.title === "string" && typeof meta.createdAt === "number" && typeof meta.lastActiveAt === "number" && typeof meta.turnCount === "number";
2054
+ }
2055
+ function listSessions(workDir) {
2056
+ try {
2057
+ const parsed = JSON.parse(fs2.readFileSync(indexPath(workDir), "utf-8"));
2058
+ if (!Array.isArray(parsed)) return [];
2059
+ return parsed.filter(isSessionMeta).sort((a, b) => b.lastActiveAt - a.lastActiveAt);
2060
+ } catch {
2061
+ return [];
2062
+ }
2063
+ }
2064
+ function latestSession(workDir) {
2065
+ return listSessions(workDir)[0];
2066
+ }
2067
+ function mutateIndex(workDir, fn) {
2068
+ const next = fn(readIndex(workDir));
2069
+ const target = indexPath(workDir);
2070
+ fs2.mkdirSync(path2.dirname(target), { recursive: true });
2071
+ const tmp = `${target}.tmp`;
2072
+ fs2.writeFileSync(tmp, JSON.stringify(next), "utf-8");
2073
+ fs2.renameSync(tmp, target);
2074
+ }
2075
+ function readIndex(workDir) {
2076
+ try {
2077
+ const parsed = JSON.parse(fs2.readFileSync(indexPath(workDir), "utf-8"));
2078
+ return Array.isArray(parsed) ? parsed.filter(isSessionMeta) : [];
2079
+ } catch {
2080
+ return [];
2081
+ }
2082
+ }
2083
+ function createSession(workDir) {
2084
+ const now = Date.now();
2085
+ const meta = {
2086
+ id: randomUUID(),
2087
+ title: "",
2088
+ createdAt: now,
2089
+ lastActiveAt: now,
2090
+ turnCount: 0
2091
+ };
2092
+ mutateIndex(workDir, (metas) => [meta, ...metas]);
2093
+ return meta;
2094
+ }
2095
+ function deleteSession(workDir, id) {
2096
+ fs2.rmSync(sessionDir(workDir, id), { recursive: true, force: true });
2097
+ mutateIndex(workDir, (metas) => metas.filter((meta) => meta.id !== id));
2098
+ }
2099
+ function touchSession(workDir, id, patch) {
2100
+ mutateIndex(
2101
+ workDir,
2102
+ (metas) => metas.map(
2103
+ (meta) => meta.id === id ? {
2104
+ ...meta,
2105
+ ...patch.title !== void 0 ? { title: patch.title.slice(0, MAX_TITLE_CHARS) } : {},
2106
+ ...patch.turnCount !== void 0 ? { turnCount: patch.turnCount } : {},
2107
+ lastActiveAt: patch.lastActiveAt ?? Date.now()
2108
+ } : meta
2109
+ )
2110
+ );
2111
+ }
2112
+
2113
+ // src/tui/history.ts
2114
+ var TURNS_FILE = "turns.jsonl";
1923
2115
  var EXPORT_DIR = "exports";
1924
- function historyPath(workDir) {
1925
- return path2.join(workDir, ".multiclaw", CONSOLE_DIR, HISTORY_FILE);
2116
+ function turnsPath(workDir, sessionId) {
2117
+ return path3.join(sessionDir(workDir, sessionId), TURNS_FILE);
1926
2118
  }
1927
2119
  function isObject(value) {
1928
2120
  return typeof value === "object" && value !== null;
@@ -1952,25 +2144,36 @@ function isTurnView(value) {
1952
2144
  ))
1953
2145
  ) && Array.isArray(turn.leaderIds);
1954
2146
  }
1955
- function loadHistory(workDir) {
2147
+ function loadTurns(workDir, sessionId) {
1956
2148
  try {
1957
- const raw = fs2.readFileSync(historyPath(workDir), "utf-8");
1958
- const parsed = JSON.parse(raw);
1959
- if (!Array.isArray(parsed)) return [];
1960
- return parsed.filter(isTurnView).slice(-MAX_HISTORY);
2149
+ const raw = fs3.readFileSync(turnsPath(workDir, sessionId), "utf-8");
2150
+ const turns = [];
2151
+ for (const line of raw.split("\n")) {
2152
+ if (line.trim() === "") continue;
2153
+ try {
2154
+ const parsed = JSON.parse(line);
2155
+ if (isTurnView(parsed)) turns.push(parsed);
2156
+ } catch {
2157
+ }
2158
+ }
2159
+ return turns.slice(-MAX_HISTORY);
1961
2160
  } catch {
1962
2161
  return [];
1963
2162
  }
1964
2163
  }
1965
- function saveHistory(workDir, turns) {
1966
- return writeJsonFile(historyPath(workDir), JSON.stringify(turns));
2164
+ function appendTurns(workDir, sessionId, turns) {
2165
+ if (turns.length === 0) return Promise.resolve();
2166
+ return appendLines(
2167
+ turnsPath(workDir, sessionId),
2168
+ turns.map((turn) => JSON.stringify(turn))
2169
+ );
1967
2170
  }
1968
- function exportPath(workDir, turn) {
1969
- return path2.join(workDir, ".multiclaw", CONSOLE_DIR, EXPORT_DIR, `turn-${turn}.md`);
2171
+ function exportPath(workDir, sessionId, turn) {
2172
+ return path3.join(sessionDir(workDir, sessionId), EXPORT_DIR, `turn-${turn}.md`);
1970
2173
  }
1971
2174
  async function exportTurn(turn, filePath) {
1972
- await fs2.promises.mkdir(path2.dirname(filePath), { recursive: true });
1973
- await fs2.promises.writeFile(filePath, formatTurnMarkdown(turn), "utf-8");
2175
+ await fs3.promises.mkdir(path3.dirname(filePath), { recursive: true });
2176
+ await fs3.promises.writeFile(filePath, formatTurnMarkdown(turn), "utf-8");
1974
2177
  }
1975
2178
  function formatTurnMarkdown(turn) {
1976
2179
  const lines = [`# Turn ${turn.turn}`, ""];
@@ -2012,13 +2215,13 @@ function formatTurnMarkdown(turn) {
2012
2215
  }
2013
2216
 
2014
2217
  // src/tui/session.ts
2015
- import * as fs3 from "fs";
2016
- import * as path3 from "path";
2218
+ import * as fs4 from "fs";
2219
+ import * as path4 from "path";
2017
2220
  var SESSION_FILE = "session.json";
2018
2221
  var SESSION_VERSION = 1;
2019
2222
  var MAX_JOURNAL_LOG_CHARS = 1200;
2020
- function sessionPath(workDir) {
2021
- return path3.join(workDir, ".multiclaw", CONSOLE_DIR, SESSION_FILE);
2223
+ function sessionPath(workDir, sessionId) {
2224
+ return path4.join(sessionDir(workDir, sessionId), SESSION_FILE);
2022
2225
  }
2023
2226
  function isObject2(value) {
2024
2227
  return typeof value === "object" && value !== null;
@@ -2040,14 +2243,13 @@ function isJournalTurn(value) {
2040
2243
  if (!isObject2(value)) return false;
2041
2244
  return Number.isInteger(value.turn) && typeof value.requirement === "string" && typeof value.savedAt === "number" && Number.isFinite(value.savedAt) && (value.startedAt === void 0 || typeof value.startedAt === "number") && (value.effectiveRequirement === void 0 || typeof value.effectiveRequirement === "string") && (value.reason === void 0 || typeof value.reason === "string") && (value.warning === void 0 || typeof value.warning === "string") && (value.focusId === void 0 || typeof value.focusId === "string") && isObject2(value.agents) && Object.values(value.agents).every(isAgentView) && Array.isArray(value.leaderIds) && value.leaderIds.every((id) => typeof id === "string") && Array.isArray(value.stages) && value.stages.every(isObject2) && (value.pending === void 0 || isObject2(value.pending) && Number.isInteger(value.pending.permissions) && Number.isInteger(value.pending.questions));
2042
2245
  }
2043
- function loadJournal(workDir) {
2246
+ function loadJournal(workDir, sessionId) {
2044
2247
  try {
2045
- const parsed = JSON.parse(fs3.readFileSync(sessionPath(workDir), "utf-8"));
2248
+ const parsed = JSON.parse(fs4.readFileSync(sessionPath(workDir, sessionId), "utf-8"));
2046
2249
  if (!isObject2(parsed) || parsed.version !== SESSION_VERSION) return void 0;
2047
2250
  const queue = Array.isArray(parsed.queue) ? parsed.queue.filter((item) => typeof item === "string" && item !== "") : [];
2048
2251
  return {
2049
2252
  version: SESSION_VERSION,
2050
- ...Number.isInteger(parsed.taskStart) && parsed.taskStart >= 0 ? { taskStart: parsed.taskStart } : {},
2051
2253
  ...queue.length > 0 ? { queue } : {},
2052
2254
  ...isJournalTurn(parsed.inFlight) ? { inFlight: parsed.inFlight } : {}
2053
2255
  };
@@ -2056,11 +2258,11 @@ function loadJournal(workDir) {
2056
2258
  }
2057
2259
  }
2058
2260
  function isEmpty(journal) {
2059
- return journal.inFlight === void 0 && journal.queue === void 0 && !journal.taskStart;
2261
+ return journal.inFlight === void 0 && journal.queue === void 0;
2060
2262
  }
2061
- function saveJournal(workDir, journal) {
2062
- const target = sessionPath(workDir);
2063
- if (isEmpty(journal) && !fs3.existsSync(target)) return Promise.resolve();
2263
+ function saveJournal(workDir, sessionId, journal) {
2264
+ const target = sessionPath(workDir, sessionId);
2265
+ if (isEmpty(journal) && !fs4.existsSync(target)) return Promise.resolve();
2064
2266
  return writeJsonFile(target, JSON.stringify(journal));
2065
2267
  }
2066
2268
  function tail(text, max) {
@@ -2114,11 +2316,12 @@ function interruptedTurn(entry) {
2114
2316
  function plural(count, word) {
2115
2317
  return count === 1 ? word : `${word}s`;
2116
2318
  }
2117
- function restoreConsole(workDir) {
2118
- const history = loadHistory(workDir);
2119
- const journal = loadJournal(workDir);
2319
+ function restoreConsole(workDir, sessionId) {
2320
+ const history = loadTurns(workDir, sessionId);
2321
+ const journal = loadJournal(workDir, sessionId);
2120
2322
  const inFlight = journal?.inFlight;
2121
2323
  const last = history[history.length - 1];
2324
+ const persistedTurn = last?.turn ?? 0;
2122
2325
  const parts = [];
2123
2326
  if (inFlight && (!last || inFlight.turn > last.turn)) {
2124
2327
  history.push(interruptedTurn(inFlight));
@@ -2138,7 +2341,7 @@ function restoreConsole(workDir) {
2138
2341
  return {
2139
2342
  history: restored,
2140
2343
  queue,
2141
- taskStart: journal?.taskStart ?? 0,
2344
+ persistedTurn,
2142
2345
  // Read from the *final* last turn: a recovered interrupted turn has no
2143
2346
  // reply, so it correctly clears the "waiting for an answer" state.
2144
2347
  awaitingAnswer: restored[restored.length - 1]?.reply?.mode === "ask",
@@ -2235,12 +2438,41 @@ function StatusLine({
2235
2438
  stages > 0 ? ` \xB7 ${stages} stages` : ""
2236
2439
  ] });
2237
2440
  }
2238
- function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }) {
2441
+ function App({
2442
+ config,
2443
+ onTurnStart,
2444
+ sessions: providedSessions,
2445
+ onCleanupReady,
2446
+ initialSession
2447
+ }) {
2239
2448
  const { exit } = useApp();
2240
2449
  const { columns, rows } = useWindowSize();
2450
+ const sessionIdRef = useRef(null);
2451
+ const launchNoticeRef = useRef(void 0);
2452
+ const openPickerOnMountRef = useRef(false);
2453
+ if (!sessionIdRef.current) {
2454
+ const latest = () => latestSession(config.workDir)?.id ?? createSession(config.workDir).id;
2455
+ if (initialSession?.kind === "new") {
2456
+ sessionIdRef.current = createSession(config.workDir).id;
2457
+ } else if (initialSession?.kind === "resume" && initialSession.id) {
2458
+ const target = initialSession.id;
2459
+ if (listSessions(config.workDir).some((s) => s.id === target)) {
2460
+ sessionIdRef.current = target;
2461
+ } else {
2462
+ sessionIdRef.current = latest();
2463
+ launchNoticeRef.current = `session "${target}" not found \u2014 opened the most recent instead`;
2464
+ }
2465
+ } else {
2466
+ sessionIdRef.current = latest();
2467
+ if (initialSession?.kind === "resume") openPickerOnMountRef.current = true;
2468
+ }
2469
+ }
2241
2470
  const restoredRef = useRef(null);
2242
- if (!restoredRef.current) restoredRef.current = restoreConsole(config.workDir);
2471
+ if (!restoredRef.current) {
2472
+ restoredRef.current = restoreConsole(config.workDir, sessionIdRef.current);
2473
+ }
2243
2474
  const restored = restoredRef.current;
2475
+ const persistedTurnRef = useRef(restored.persistedTurn);
2244
2476
  const [state, dispatch] = useReducer(
2245
2477
  tuiReducer,
2246
2478
  config,
@@ -2257,10 +2489,15 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2257
2489
  queueRef.current = queue;
2258
2490
  const [cancelled, setCancelled] = useState(false);
2259
2491
  const [error, setError] = useState();
2260
- const [notice, setNotice] = useState(restored.notice);
2492
+ const [notice, setNotice] = useState(
2493
+ launchNoticeRef.current ?? restored.notice
2494
+ );
2261
2495
  const [helpOpen, setHelpOpen] = useState(false);
2262
2496
  const [historyOpen, setHistoryOpen] = useState(false);
2263
2497
  const [historyCursor, setHistoryCursor] = useState(0);
2498
+ const [sessionsOpen, setSessionsOpen] = useState(false);
2499
+ const [sessionCursor, setSessionCursor] = useState(0);
2500
+ const [sessionList, setSessionList] = useState([]);
2264
2501
  const [menuCursor, setMenuCursor] = useState({
2265
2502
  query: void 0,
2266
2503
  index: 0
@@ -2274,7 +2511,6 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2274
2511
  const closingRef = useRef(false);
2275
2512
  const shutdownRef = useRef(null);
2276
2513
  const busyRef = useRef(false);
2277
- const taskStartRef = useRef(restored.taskStart);
2278
2514
  const turnSerialRef = useRef(0);
2279
2515
  const [awaitingAnswer, setAwaitingAnswer] = useState(restored.awaitingAnswer);
2280
2516
  const [queuePaused, setQueuePaused] = useState(restored.queue.length > 0);
@@ -2375,16 +2611,21 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2375
2611
  const timer = setInterval(() => setCaretOn((on) => !on), BLINK_INTERVAL_MS);
2376
2612
  return () => clearInterval(timer);
2377
2613
  }, [running, line.text]);
2378
- const lastSaved = useRef(void 0);
2379
2614
  useEffect(() => {
2380
- const turns = recordedTurns(stateRef.current).slice(-MAX_HISTORY);
2381
- if (turns.length === 0) return;
2382
- const json = JSON.stringify(turns);
2383
- if (json === lastSaved.current) return;
2384
- lastSaved.current = json;
2385
- void saveHistory(config.workDir, turns).catch(
2615
+ const sessionId = sessionIdRef.current;
2616
+ if (!sessionId) return;
2617
+ const turns = recordedTurns(stateRef.current);
2618
+ const fresh = turns.filter((turn) => turn.turn > persistedTurnRef.current);
2619
+ if (fresh.length === 0) return;
2620
+ persistedTurnRef.current = fresh[fresh.length - 1].turn;
2621
+ void appendTurns(config.workDir, sessionId, fresh).catch(
2386
2622
  (err) => setNotice(`history not saved: ${err.message}`)
2387
2623
  );
2624
+ const title = turns.find((turn) => turn.requirement)?.requirement;
2625
+ touchSession(config.workDir, sessionId, {
2626
+ turnCount: fresh[fresh.length - 1].turn,
2627
+ ...title ? { title } : {}
2628
+ });
2388
2629
  }, [state.history, state.phase]);
2389
2630
  const persistSession = useCallback(() => {
2390
2631
  if (closingRef.current) return;
@@ -2394,15 +2635,16 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2394
2635
  if (entry.kind === "permission") waiting.permissions += 1;
2395
2636
  else waiting.questions += 1;
2396
2637
  }
2638
+ const sessionId = sessionIdRef.current;
2639
+ if (!sessionId) return;
2397
2640
  const journal = {
2398
2641
  version: SESSION_VERSION,
2399
- taskStart: taskStartRef.current,
2400
2642
  ...queueRef.current.length > 0 ? { queue: [...queueRef.current] } : {},
2401
2643
  // Only a turn that is actually running is in flight. Writing one for an
2402
2644
  // idle console would invent work the next session would then "recover".
2403
2645
  ...current.phase === "running" && current.requirement !== void 0 ? { inFlight: journalTurn(current, waiting) } : {}
2404
2646
  };
2405
- void saveJournal(config.workDir, journal).catch((err) => {
2647
+ void saveJournal(config.workDir, sessionId, journal).catch((err) => {
2406
2648
  if (mountedRef.current) setNotice(`session not saved: ${err.message}`);
2407
2649
  });
2408
2650
  }, [config.workDir]);
@@ -2424,10 +2666,10 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2424
2666
  if (closingRef.current || busyRef.current) return;
2425
2667
  busyRef.current = true;
2426
2668
  setRunning(true);
2427
- const continuation = opts?.newTask ? null : continuationRef.current;
2669
+ const continuation = opts?.newSession ? null : continuationRef.current;
2428
2670
  continuationRef.current = null;
2429
2671
  const resume = opts?.resume ?? continuationResume(continuation, sessionsRef.current);
2430
- if (opts?.newTask) {
2672
+ if (opts?.newSession) {
2431
2673
  try {
2432
2674
  await sessionsRef.current?.reset();
2433
2675
  if (closingRef.current) {
@@ -2435,7 +2677,14 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2435
2677
  setRunning(false);
2436
2678
  return;
2437
2679
  }
2438
- taskStartRef.current = stateRef.current.turn;
2680
+ const created = createSession(config.workDir);
2681
+ sessionIdRef.current = created.id;
2682
+ persistedTurnRef.current = 0;
2683
+ dispatch({
2684
+ type: "session:reset",
2685
+ agents: seedAgents(config),
2686
+ leaderIds: leaderAgentIds(config)
2687
+ });
2439
2688
  } catch (err) {
2440
2689
  busyRef.current = false;
2441
2690
  setRunning(false);
@@ -2453,11 +2702,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2453
2702
  setError(void 0);
2454
2703
  setCancelled(false);
2455
2704
  setNotice(void 0);
2456
- const earlierTurns = conversationTurns({
2457
- ...stateRef.current,
2458
- history: stateRef.current.history.filter((turn) => turn.turn > taskStartRef.current),
2459
- ...stateRef.current.turn <= taskStartRef.current ? { phase: "idle" } : {}
2460
- });
2705
+ const earlierTurns = opts?.newSession ? [] : conversationTurns(stateRef.current);
2461
2706
  dispatch({
2462
2707
  type: "turn:start",
2463
2708
  requirement,
@@ -2466,7 +2711,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2466
2711
  startedAt: Date.now()
2467
2712
  });
2468
2713
  const stamp = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${++turnSerialRef.current}`;
2469
- const logDir = path4.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
2714
+ const logDir = path5.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
2470
2715
  const runConfig = {
2471
2716
  ...config,
2472
2717
  context: { ...config.context, requirement },
@@ -2549,16 +2794,53 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2549
2794
  setQueue(rest);
2550
2795
  void startTurn(next);
2551
2796
  }, [running, queue, queuePaused, awaitingAnswer, startTurn]);
2797
+ const switchSession = useCallback(
2798
+ async (id) => {
2799
+ if (id === sessionIdRef.current || busyRef.current || closingRef.current) return;
2800
+ try {
2801
+ await sessionsRef.current?.reset();
2802
+ } catch (err) {
2803
+ setError(`session switch failed: ${err.message}`);
2804
+ return;
2805
+ }
2806
+ if (closingRef.current) return;
2807
+ const restored2 = restoreConsole(config.workDir, id);
2808
+ sessionIdRef.current = id;
2809
+ persistedTurnRef.current = restored2.persistedTurn;
2810
+ const resumable = [...restored2.history].reverse().find((turn) => Object.keys(turnSessions(turn)).length > 0);
2811
+ continuationRef.current = resumable ? { turn: resumable.turn, sessions: turnSessions(resumable) } : null;
2812
+ dispatch({
2813
+ type: "session:load",
2814
+ agents: seedAgents(config),
2815
+ leaderIds: leaderAgentIds(config),
2816
+ history: restored2.history
2817
+ });
2818
+ setQueue(restored2.queue);
2819
+ queuePausedRef.current = restored2.queue.length > 0;
2820
+ setQueuePaused(restored2.queue.length > 0);
2821
+ awaitingAnswerRef.current = restored2.awaitingAnswer;
2822
+ setAwaitingAnswer(restored2.awaitingAnswer);
2823
+ setError(void 0);
2824
+ setCancelled(false);
2825
+ setNotice(
2826
+ restored2.notice ?? (resumable ? `switched session \xB7 continue with your next instruction` : `switched session \xB7 ${restored2.history.length} turn${restored2.history.length === 1 ? "" : "s"}`)
2827
+ );
2828
+ },
2829
+ [config]
2830
+ );
2552
2831
  const cleanup = useCallback(async () => {
2553
2832
  closingRef.current = true;
2554
2833
  abortRef.current?.abort();
2555
2834
  clearInteractions();
2556
2835
  await activeTurnRef.current;
2557
2836
  await sessionsRef.current?.close();
2558
- await Promise.all([
2559
- pendingWrite(historyPath(config.workDir)),
2560
- pendingWrite(sessionPath(config.workDir))
2561
- ]);
2837
+ const sessionId = sessionIdRef.current;
2838
+ if (sessionId) {
2839
+ await Promise.all([
2840
+ pendingWrite(turnsPath(config.workDir, sessionId)),
2841
+ pendingWrite(sessionPath(config.workDir, sessionId))
2842
+ ]);
2843
+ }
2562
2844
  if (reporterErrorRef.current) throw reporterErrorRef.current;
2563
2845
  }, [clearInteractions, config.workDir]);
2564
2846
  useEffect(() => {
@@ -2570,6 +2852,15 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2570
2852
  });
2571
2853
  };
2572
2854
  }, [cleanup, onCleanupReady]);
2855
+ useEffect(() => {
2856
+ if (!openPickerOnMountRef.current) return;
2857
+ openPickerOnMountRef.current = false;
2858
+ const list = listSessions(config.workDir);
2859
+ setSessionList(list);
2860
+ const current = list.findIndex((session) => session.id === sessionIdRef.current);
2861
+ setSessionCursor(current === -1 ? 0 : current);
2862
+ setSessionsOpen(true);
2863
+ }, []);
2573
2864
  const shutdown = useCallback(() => {
2574
2865
  if (shutdownRef.current) return shutdownRef.current;
2575
2866
  closingRef.current = true;
@@ -2582,13 +2873,20 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2582
2873
  shutdownRef.current = (async () => {
2583
2874
  try {
2584
2875
  await cleanup();
2585
- const turns = recordedTurns(stateRef.current).slice(-MAX_HISTORY);
2586
- if (turns.length) await saveHistory(config.workDir, turns);
2587
- await saveJournal(config.workDir, {
2588
- version: SESSION_VERSION,
2589
- taskStart: taskStartRef.current,
2590
- ...leftover.length > 0 ? { queue: leftover } : {}
2591
- });
2876
+ const sessionId = sessionIdRef.current;
2877
+ if (sessionId) {
2878
+ const fresh = recordedTurns(stateRef.current).filter(
2879
+ (turn) => turn.turn > persistedTurnRef.current
2880
+ );
2881
+ if (fresh.length) {
2882
+ persistedTurnRef.current = fresh[fresh.length - 1].turn;
2883
+ await appendTurns(config.workDir, sessionId, fresh);
2884
+ }
2885
+ await saveJournal(config.workDir, sessionId, {
2886
+ version: SESSION_VERSION,
2887
+ ...leftover.length > 0 ? { queue: leftover } : {}
2888
+ });
2889
+ }
2592
2890
  exit();
2593
2891
  } catch (err) {
2594
2892
  setError(`shutdown failed: ${err.message}`);
@@ -2617,8 +2915,8 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2617
2915
  const paletteTotal = allRows.length;
2618
2916
  const menuIndex = menuCursor.query === paletteKey ? menuCursor.index : 0;
2619
2917
  const selected = paletteTotal === 0 ? 0 : Math.min(menuIndex, paletteTotal - 1);
2620
- const menuOpen = !helpOpen && !historyOpen && query !== void 0 && matches.length > 0;
2621
- const argOpen = !helpOpen && !historyOpen && arg !== void 0 && argRows.length > 0;
2918
+ const menuOpen = !helpOpen && !historyOpen && !sessionsOpen && query !== void 0 && matches.length > 0;
2919
+ const argOpen = !helpOpen && !historyOpen && !sessionsOpen && arg !== void 0 && argRows.length > 0;
2622
2920
  const paletteOpen = menuOpen || argOpen;
2623
2921
  const front = interactions[0];
2624
2922
  const menu = front?.kind === "permission" && permissionMenu.id === front.shortId ? permissionMenu : { id: front?.shortId ?? "", cursor: void 0, released: false };
@@ -2639,7 +2937,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2639
2937
  columns,
2640
2938
  rows,
2641
2939
  turn: state.turn,
2642
- overlay: helpOpen || historyOpen,
2940
+ overlay: helpOpen || historyOpen || sessionsOpen,
2643
2941
  menuRows: paletteOpen ? paletteTotal : 0,
2644
2942
  queueRows: queue.length,
2645
2943
  interactionRows: interactionRows(interactions, Math.max(MIN_COLUMNS, columns)),
@@ -2785,16 +3083,16 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2785
3083
  setNotice(`selected ${clean(option.name)} for ${entry.shortId}`);
2786
3084
  return;
2787
3085
  }
2788
- case "task": {
3086
+ case "new": {
2789
3087
  const text = args.join(" ");
2790
3088
  if (!text) {
2791
- setNotice("usage: /task <new requirement> \u2014 closes prior agent sessions");
3089
+ setNotice("usage: /new <requirement> \u2014 starts a new session with fresh agent sessions");
2792
3090
  return;
2793
3091
  }
2794
3092
  setQueue([]);
2795
3093
  queuePausedRef.current = false;
2796
3094
  setQueuePaused(false);
2797
- void startTurn(text, { newTask: true });
3095
+ void startTurn(text, { newSession: true });
2798
3096
  return;
2799
3097
  }
2800
3098
  case "answer": {
@@ -2892,6 +3190,15 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2892
3190
  setHistoryCursor(0);
2893
3191
  setHistoryOpen(true);
2894
3192
  return;
3193
+ case "sessions": {
3194
+ const list = listSessions(config.workDir);
3195
+ setSessionList(list);
3196
+ const current = list.findIndex((session) => session.id === sessionIdRef.current);
3197
+ setSessionCursor(current === -1 ? 0 : current);
3198
+ setNotice(void 0);
3199
+ setSessionsOpen(true);
3200
+ return;
3201
+ }
2895
3202
  case "verbose":
2896
3203
  dispatch({ type: "verbose:toggle" });
2897
3204
  setNotice(state.verbose ? "verbose off" : "verbose on \u2014 showing tool detail");
@@ -2969,7 +3276,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
2969
3276
  setNotice(target.error);
2970
3277
  return;
2971
3278
  }
2972
- const file = args[1] ? path4.resolve(config.workDir, args[1]) : exportPath(config.workDir, target.record.turn);
3279
+ const file = args[1] ? path5.resolve(config.workDir, args[1]) : exportPath(config.workDir, sessionIdRef.current ?? "", target.record.turn);
2973
3280
  void exportTurn(target.record, file).then(() => setNotice(`exported turn ${target.record.turn} to ${file}`)).catch((err) => setNotice(`export failed: ${err.message}`));
2974
3281
  return;
2975
3282
  }
@@ -3090,6 +3397,8 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
3090
3397
  setHelpOpen(false);
3091
3398
  } else if (historyOpen) {
3092
3399
  setHistoryOpen(false);
3400
+ } else if (sessionsOpen) {
3401
+ setSessionsOpen(false);
3093
3402
  } else if (abortRef.current) {
3094
3403
  abortRef.current.abort();
3095
3404
  clearInteractions();
@@ -3144,6 +3453,50 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
3144
3453
  }
3145
3454
  return;
3146
3455
  }
3456
+ if (sessionsOpen) {
3457
+ if (key.escape) {
3458
+ setSessionsOpen(false);
3459
+ return;
3460
+ }
3461
+ if (sessionList.length === 0) return;
3462
+ const index = Math.min(sessionCursor, sessionList.length - 1);
3463
+ if (key.upArrow) {
3464
+ setSessionCursor((index - 1 + sessionList.length) % sessionList.length);
3465
+ return;
3466
+ }
3467
+ if (key.downArrow) {
3468
+ setSessionCursor((index + 1) % sessionList.length);
3469
+ return;
3470
+ }
3471
+ if (key.return) {
3472
+ const target = sessionList[index];
3473
+ setSessionsOpen(false);
3474
+ if (target.id === sessionIdRef.current) {
3475
+ setNotice("already on this session");
3476
+ return;
3477
+ }
3478
+ if (running || busyRef.current) {
3479
+ setNotice("finish or cancel the current turn before switching sessions");
3480
+ return;
3481
+ }
3482
+ void switchSession(target.id);
3483
+ return;
3484
+ }
3485
+ if (char === "d") {
3486
+ const target = sessionList[index];
3487
+ if (target.id === sessionIdRef.current) {
3488
+ setNotice("can't delete the current session");
3489
+ return;
3490
+ }
3491
+ deleteSession(config.workDir, target.id);
3492
+ const next = listSessions(config.workDir);
3493
+ setSessionList(next);
3494
+ setSessionCursor(Math.min(index, Math.max(0, next.length - 1)));
3495
+ setNotice(`deleted session \xB7 ${next.length} left`);
3496
+ return;
3497
+ }
3498
+ return;
3499
+ }
3147
3500
  if (key.ctrl && char === "d") {
3148
3501
  if (!line.text) void shutdown();
3149
3502
  return;
@@ -3390,7 +3743,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
3390
3743
  const handlerRef = useRef(handleKey);
3391
3744
  handlerRef.current = handleKey;
3392
3745
  useInput((char, key) => handlerRef.current(char, key));
3393
- const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 /modify <instruction> \xB7 /cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
3746
+ const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : sessionsOpen ? "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 /modify <instruction> \xB7 /cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
3394
3747
  // Too short to dock the bar, so the commands are the only way in.
3395
3748
  "decide with /approve 1 \xB7 /deny \xB7 /respond"
3396
3749
  ) : interactions[0].kind === "question" ? "tab field \xB7 \u2191\u2193 pick \xB7 enter next \xB7 /dismiss" : menu.released ? "/approve 1 and /deny still reach it" : "\u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type" : "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
@@ -3428,6 +3781,16 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
3428
3781
  width: layout.columns,
3429
3782
  maxRows: layout.bodyHeight
3430
3783
  }
3784
+ ) : sessionsOpen ? /* @__PURE__ */ jsx6(
3785
+ SessionsOverlay,
3786
+ {
3787
+ sessions: sessionList,
3788
+ selected: Math.min(sessionCursor, Math.max(0, sessionList.length - 1)),
3789
+ currentId: sessionIdRef.current ?? void 0,
3790
+ width: layout.columns,
3791
+ maxRows: layout.bodyHeight,
3792
+ now
3793
+ }
3431
3794
  ) : layout.welcome ? /* @__PURE__ */ jsx6(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
3432
3795
  // The error wins over a reply: a run that stopped with an error is the
3433
3796
  // most urgent thing on screen, and a stale answer underneath it would
@@ -3537,9 +3900,16 @@ async function startTui(options) {
3537
3900
  cleanup = callback;
3538
3901
  };
3539
3902
  try {
3540
- const app = render(createElement(App, { ...options, sessions, onCleanupReady }), {
3541
- exitOnCtrlC: false
3542
- });
3903
+ const app = render(
3904
+ createElement(App, {
3905
+ config: options.config,
3906
+ onTurnStart: options.onTurnStart,
3907
+ initialSession: options.initialSession,
3908
+ sessions,
3909
+ onCleanupReady
3910
+ }),
3911
+ { exitOnCtrlC: false }
3912
+ );
3543
3913
  await app.waitUntilExit();
3544
3914
  } finally {
3545
3915
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiclawcli/dashboard",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tui": {
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@multiclawcli/core": "0.9.0",
28
+ "@multiclawcli/core": "0.10.0",
29
29
  "ink": "^8.0.0",
30
30
  "react": "^19.3.1",
31
31
  "string-width": "^8.3.0"