parallel-codex-tui 0.2.8 → 0.2.9

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/README.md CHANGED
@@ -6,7 +6,7 @@ Built with Codex-assisted development.
6
6
 
7
7
  ## Current Release
8
8
 
9
- `v0.2.8` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.8) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.8). `Ctrl+N` now creates an explicit, durable Main conversation boundary even when no Task is active. It preserves the complete `chat.jsonl`, writes the current scope to `conversation.json`, retires the previous Main native session, and keeps legacy records readable until the first explicit boundary. New prompts and on-demand `conversation.jsonl` snapshots include only the exact current `conversation_id`, while Task chat remains isolated by `task_id`.
9
+ `v0.2.9` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.9) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.9). `Ctrl+T`, then `C`, now opens every persisted Main conversation in the workspace. Restoring one switches the exact `conversation_id`, reloads only that conversation's file-backed history, and restores its archived native Codex or Claude session ids when they are available. `Ctrl+N` still creates an explicit, durable Main conversation boundary without deleting chat, Tasks, or Worker logs.
10
10
 
11
11
  A real Claude acceptance retires the first native session, inserts 16 newer records so the secret is absent from the inline transcript, then proves a fresh Claude session can read the scoped snapshot and recover it. The same live TUI presses `Ctrl+N`, verifies the replacement session is retired, starts another native session whose prompt and archive exclude the secret, and then switches between two isolated workspaces. The bounded rollover fallback remains included: every Main call rebuilds recent context from canonical `chat.jsonl`, so a fresh Agent still receives the latest dialogue inside its current scope.
12
12
 
@@ -23,9 +23,9 @@ Highlights:
23
23
  - `--diagnostics` and `Ctrl+X` export bounded, redacted support evidence without prompts, role instructions, command arguments, source files, or environment-variable values.
24
24
  - Runtime preflight checks Codex, Claude, named third-party Providers, proxy reachability, workspace access, CLI capabilities, and native attach policy before work starts.
25
25
  - Named Worker Providers support Codex-compatible, Claude-compatible, OpenAI-compatible, Anthropic-compatible, and custom generic commands with independent role, model, environment, permission, resume, and interactive settings.
26
- - Worker overview, Feature board, collaboration timeline, Task center, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
26
+ - Worker overview, Feature board, collaboration timeline, Task and Main conversation centers, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
27
27
 
28
- Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. The deterministic repository suite contains 1,294 tests across 127 files: 1,293 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
28
+ Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. The deterministic repository suite contains 1,305 tests across 129 files: 1,304 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
29
29
 
30
30
  Real Provider probes depend on valid local CLI credentials. In particular, authenticate the Claude CLI before selecting a Claude-compatible Worker, then run `parallel-codex-tui --doctor --probe-agents` to prove fresh and resumed calls on that machine.
31
31
 
@@ -209,7 +209,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
209
209
  - Router classification only receives the user request; workspace selection and session files are kept out of the router prompt.
210
210
  - Simple requests stay in the main TUI flow and do not create Judge, Actor, or Critic workers.
211
211
  - Consecutive simple requests reuse the main worker's native session across app restarts when the CLI exposes a session id. Every Main call also rebuilds a bounded fallback transcript from the workspace `chat.jsonl`: the current ordinary conversation and each Task are isolated, the just-submitted request is removed, and at most 12 recent records or 6,000 characters are injected. A separate scoped `main-<provider>/conversation.jsonl` snapshot keeps up to 200 records for details outside that inline window; Main is told to read it only when needed and to treat it as context rather than instructions. This keeps multi-turn context available when native resume must roll over to a fresh Agent session without inflating every prompt.
212
- - The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. Before the first explicit boundary, records without `conversation_id` remain the compatible legacy conversation. `Ctrl+N` writes `.parallel-codex/sessions/main/conversation.json`, tags later ordinary records with its id, retires every active Main native session, and leaves the complete transcript untouched. Main stores only the extracted final answer in new chat records while retaining the raw Codex/Claude process transcript in the Main Worker's `output.log`. Legacy Codex transcript records are reduced to their final answer at read time without changing the original JSONL. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
212
+ - The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. Before the first explicit boundary, records without `conversation_id` remain the compatible legacy conversation. `Ctrl+N` writes `.parallel-codex/sessions/main/conversation.json`, tags later ordinary records with its id, retires every active Main native session, and leaves the complete transcript untouched. Conversation metadata and reusable native session records are archived under `.parallel-codex/sessions/main/conversations/<conversation>/`; selecting an older conversation intentionally removes its retirement barrier and restores those exact Main bindings. Main stores only the extracted final answer in new chat records while retaining the raw Codex/Claude process transcript in the Main Worker's `output.log`. Legacy Codex transcript records are reduced to their final answer at read time without changing the original JSONL. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
213
213
  - Chat drafts support Unicode-safe Left/Right, Home/End, Backspace, and Delete editing. A single Up/Down recalls persisted user requests and returns to the exact unsent draft; Ctrl+Up/Down remains available, while repeated alternate-scroll arrow bursts continue to scroll chat. Long input stays on one row with the visible window centered around the logical cursor.
214
214
  - Bracketed multiline paste stays in one draft instead of submitting the first line; logical line breaks and tabs appear as `↵` and `⇥` in the single-row input until the complete request is submitted.
215
215
  - Complex requests create a session under `.parallel-codex/sessions/`.
@@ -467,7 +467,9 @@ For a failed, cancelled, or paused task, select an unfinished Feature and press
467
467
 
468
468
  From Worker overview, press `C` to open the file-backed Actor/Critic collaboration timeline. It merges `dialogue/actor-critic.jsonl`, feature status, Critic findings, Actor replies, finding resolution, and Wave events into one chronological view. The timeline follows new file evidence automatically and shows verified `fixed`/`open` counts when resolution evidence exists. Up/Down selects a collaboration event; `Enter` opens its complete event detail, including artifact paths from dialogue, status, Critic findings, Actor replies, and finding resolution; `U` filters to unresolved feature evidence; `Tab` cycles all features and each individual feature; `R` refreshes immediately; the mouse wheel or PageUp/PageDown scrolls history; and `Esc` returns to Worker overview.
469
469
 
470
- `Ctrl+T` opens the workspace's persisted Task sessions without exiting the outer TUI. The list shows status, creation time, turn and Worker counts, plus the number of distinct native sessions after deduplicating reused `engine + session_id` bindings; `>` marks the selected row and `*` marks the active Task. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select, Enter to restore, `I` to inspect the complete session hierarchy, `R` to rename with Unicode-safe cursor editing, `A` to archive or unarchive, `D` twice to confirm deletion, `E` to export, `H` to show or hide archived sessions, `Ctrl+N` to leave the active Task and start a fresh Main conversation, and `Esc` to return without losing the chat draft. Archived sessions are hidden by default, cannot be restored until unarchived, and are excluded from automatic startup selection. Archive, delete, and export require a terminal Task with no live task lease; the active Task cannot be archived or deleted. Exports are complete file-backed snapshots under `.parallel-codex/exports/<task>-<timestamp>/` with a versioned manifest and without transient lease files. Restoring a Task reloads its route, Workers, retry state, and recorded native session ids. The selected active Task is stored in `session-index.sqlite` and survives process and workspace switches; `Ctrl+N` persists an intentionally empty active-Task context instead of reopening an older Task on restart. SQLite schema changes run as ordered, transactional migrations. Existing catalogs receive a pre-migration snapshot, each successful startup/reindex refreshes a healthy backup, and an integrity failure restores that backup or rebuilds the catalog from authoritative Task files while preserving the corrupt copy for inspection. `meta.json` and the Worker files remain the rebuild authority.
470
+ `Ctrl+T` opens the workspace's persisted Task sessions without exiting the outer TUI. The list shows status, creation time, turn and Worker counts, plus the number of distinct native sessions after deduplicating reused `engine + session_id` bindings; `>` marks the selected row and `*` marks the active Task. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select, Enter to restore, `C` to open Main conversations, `I` to inspect the complete session hierarchy, `R` to rename with Unicode-safe cursor editing, `A` to archive or unarchive, `D` twice to confirm deletion, `E` to export, `H` to show or hide archived sessions, `Ctrl+N` to leave the active Task and start a fresh Main conversation, and `Esc` to return without losing the chat draft. Archived sessions are hidden by default, cannot be restored until unarchived, and are excluded from automatic startup selection. Archive, delete, and export require a terminal Task with no live task lease; the active Task cannot be archived or deleted. Exports are complete file-backed snapshots under `.parallel-codex/exports/<task>-<timestamp>/` with a versioned manifest and without transient lease files. Restoring a Task reloads its route, Workers, retry state, and recorded native session ids. The selected active Task is stored in `session-index.sqlite` and survives process and workspace switches; `Ctrl+N` persists an intentionally empty active-Task context instead of reopening an older Task on restart. SQLite schema changes run as ordered, transactional migrations. Existing catalogs receive a pre-migration snapshot, each successful startup/reindex refreshes a healthy backup, and an integrity failure restores that backup or rebuilds the catalog from authoritative Task files while preserving the corrupt copy for inspection. `meta.json` and the Worker files remain the rebuild authority.
471
+
472
+ The Main conversation list shows each scoped title, message count, distinct native session count, last activity, current `*` marker, and selected `>` marker. Use the same Up/Down, PageUp/PageDown, mouse-wheel, or `Tab` navigation; Enter restores the selected conversation, `N` starts a new one, `T` returns to Tasks, and `Esc` returns to chat. Restoration preserves every Task and Worker log, switches `conversation.json`, and rebuilds Main history from records carrying only the selected `conversation_id` (or the pre-boundary legacy scope). Before switching, active Main native sessions are archived and retired; archived bindings for the selected conversation are then restored for best-effort native resume. A brand-new empty legacy scope is not added to the catalog.
471
473
 
472
474
  The Task detail view reads authoritative task files and presents `Project -> Task -> Turn -> Worker -> Native session`, including each request, role, engine, configured model, Worker state, native session id, working directory, and last activity time. Historical Workers from earlier turns remain selectable after same-task follow-ups. Use Up/Down or `Tab` to select a Worker, Enter to open its retained log, `C` or `Ctrl+O` to continue the original native session, `B` to ask the native CLI to fork it, `R` to refresh, and `Esc` to return to the Task list. Continue and fork restore the selected Worker's recorded cwd and writable roots while the outer TUI stays open. Codex and Claude have built-in fork commands; a generic CLI exposes `B` only when `interactive.forkArgs` is configured. The child fork is owned and persisted by the native CLI; the indexed parent session remains unchanged.
473
475
 
@@ -521,12 +523,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
521
523
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
522
524
 
523
525
  ```bash
524
- VERSION=0.2.8
526
+ VERSION=0.2.9
525
527
  git tag "v$VERSION"
526
528
  git push origin "v$VERSION"
527
529
  ```
528
530
 
529
- You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.2.8` requires tag `v0.2.8`. Published tags such as `v0.2.7` are immutable and must not be moved or reused.
531
+ You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.2.9` requires tag `v0.2.9`. Published tags such as `v0.2.8` are immutable and must not be moved or reused.
530
532
 
531
533
  ## Publishing Hygiene
532
534
 
package/dist/cli.js CHANGED
@@ -127,7 +127,11 @@ async function main() {
127
127
  records,
128
128
  policy: routerDiagnosticsPolicy(latestConfig.router)
129
129
  };
130
- }, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), loadTaskSessionDetails: (task) => loadPersistedTaskSessionDetails({
130
+ }, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), loadMainConversations: () => state.runtime.sessions.listMainConversations(100), activateMainConversation: async (conversationId) => {
131
+ const restored = await state.runtime.sessions.activateMainConversation(conversationId);
132
+ await state.runtime.index.setActiveTaskId(null);
133
+ return restored;
134
+ }, loadTaskSessionDetails: (task) => loadPersistedTaskSessionDetails({
131
135
  task,
132
136
  taskDir: state.runtime.sessions.taskFromId(task.id).dir,
133
137
  modelNames: Object.fromEntries(Object.entries(state.runtime.config.workers).map(([id, worker]) => [id, worker.model.name]))
@@ -11,7 +11,7 @@ import { loadCollaborationTimeline } from "./collaboration-timeline.js";
11
11
  import { taskStateTransitionAllowed } from "./task-state-machine.js";
12
12
  import { processIsAlive, readProcessStartToken } from "./process-identity.js";
13
13
  import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
14
- import { ChatRecordSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
14
+ import { ChatRecordSchema, ConversationIdSchema, MainConversationArchiveSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
15
15
  export class InterruptedTaskRecoveryBlockedError extends Error {
16
16
  taskId;
17
17
  blocks;
@@ -42,6 +42,9 @@ const ACTIVE_FEATURE_STATES = new Set([
42
42
  ]);
43
43
  const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
44
44
  const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
45
+ const MAIN_CONVERSATION_ARCHIVE_DIRECTORY = "conversations";
46
+ const LEGACY_MAIN_CONVERSATION_DIRECTORY = "legacy";
47
+ const SAFE_MAIN_WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
45
48
  const CompletionContractSchema = z.object({
46
49
  version: z.literal(1),
47
50
  final_judge_required: z.literal(true)
@@ -180,24 +183,91 @@ export class SessionManager {
180
183
  }
181
184
  return readJson(path, MainConversationStateSchema);
182
185
  }
186
+ async listMainConversations(limit = 100) {
187
+ const boundedLimit = Number.isFinite(limit)
188
+ ? Math.min(500, Math.max(0, Math.trunc(limit)))
189
+ : 100;
190
+ if (boundedLimit === 0) {
191
+ return [];
192
+ }
193
+ const [current, records, archives] = await Promise.all([
194
+ this.readMainConversationState(),
195
+ readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, 10000, { filter: (record) => !record.task_id }),
196
+ this.readMainConversationArchives()
197
+ ]);
198
+ const currentId = current?.id ?? null;
199
+ const conversations = new Map();
200
+ const ensureConversation = (id, createdAt, lastActivityAt = createdAt) => {
201
+ const key = mainConversationScopeKey(id);
202
+ const existing = conversations.get(key);
203
+ if (existing) {
204
+ if (createdAt < existing.createdAt) {
205
+ existing.createdAt = createdAt;
206
+ }
207
+ if (lastActivityAt > existing.lastActivityAt) {
208
+ existing.lastActivityAt = lastActivityAt;
209
+ }
210
+ return existing;
211
+ }
212
+ const summary = {
213
+ id,
214
+ title: id ? "Untitled conversation" : "Legacy conversation",
215
+ createdAt,
216
+ lastActivityAt,
217
+ messageCount: 0,
218
+ userMessageCount: 0,
219
+ nativeSessionCount: 0,
220
+ current: id === currentId
221
+ };
222
+ conversations.set(key, summary);
223
+ return summary;
224
+ };
225
+ for (const archive of archives) {
226
+ ensureConversation(archive.id, archive.created_at, archive.last_activated_at);
227
+ }
228
+ for (const record of records) {
229
+ const id = record.conversation_id ?? null;
230
+ const conversation = ensureConversation(id, record.time, record.time);
231
+ conversation.messageCount += 1;
232
+ if (record.from === "user") {
233
+ conversation.userMessageCount += 1;
234
+ if (conversation.title === "Untitled conversation" || conversation.title === "Legacy conversation") {
235
+ conversation.title = mainConversationTitle(record.text, id);
236
+ }
237
+ }
238
+ }
239
+ if (current) {
240
+ ensureConversation(current.id, current.created_at, current.created_at).current = true;
241
+ }
242
+ else if (!conversations.has(LEGACY_MAIN_CONVERSATION_DIRECTORY)) {
243
+ const createdAt = this.now().toISOString();
244
+ ensureConversation(null, createdAt, createdAt).current = true;
245
+ }
246
+ const activeNativeSessions = await this.activeMainNativeSessionIds();
247
+ await Promise.all([...conversations.values()].map(async (conversation) => {
248
+ const archived = await this.archivedMainNativeSessionIds(conversation.id);
249
+ if (conversation.current) {
250
+ for (const sessionId of activeNativeSessions) {
251
+ archived.add(sessionId);
252
+ }
253
+ }
254
+ conversation.nativeSessionCount = archived.size;
255
+ }));
256
+ return [...conversations.values()]
257
+ .sort((left, right) => (Number(right.current) - Number(left.current)
258
+ || right.lastActivityAt.localeCompare(left.lastActivityAt)
259
+ || mainConversationScopeKey(right.id).localeCompare(mainConversationScopeKey(left.id))))
260
+ .slice(0, boundedLimit);
261
+ }
183
262
  async startNewMainConversation() {
184
263
  const main = this.taskFromId("main");
185
264
  await ensureDir(main.dir);
186
265
  const lease = await this.claimTaskRunLease(main.dir);
187
266
  return runWithLeaseFinalization("Main conversation reset", lease, async () => {
188
267
  const previous = await this.readMainConversationState();
189
- const entries = await readdir(main.dir, { withFileTypes: true });
190
- for (const entry of entries) {
191
- if (!entry.isDirectory()) {
192
- continue;
193
- }
194
- const worker = { dir: join(main.dir, entry.name) };
195
- const nativeSession = await this.readNativeSession(worker);
196
- if (nativeSession?.scope === "main") {
197
- await this.retireNativeSession(worker, "new Main conversation");
198
- }
199
- }
200
268
  const createdAt = this.now();
269
+ const currentSummary = (await this.listMainConversations(500)).find((conversation) => conversation.current);
270
+ await this.archiveAndRetireMainConversation(main, previous?.id ?? null, currentSummary?.createdAt ?? previous?.created_at ?? createdAt.toISOString(), "new Main conversation", Boolean(previous) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
201
271
  const baseId = `conversation-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
202
272
  const state = MainConversationStateSchema.parse({
203
273
  version: 1,
@@ -206,10 +276,61 @@ export class SessionManager {
206
276
  ...(previous ? { previous_id: previous.id } : {})
207
277
  });
208
278
  await writeJson(join(main.dir, "conversation.json"), state);
279
+ await this.writeMainConversationArchive({
280
+ version: 1,
281
+ id: state.id,
282
+ created_at: state.created_at,
283
+ last_activated_at: state.created_at
284
+ });
209
285
  await this.appendEvent(main, "main.conversation_started", `Started ${state.id}`);
210
286
  return state;
211
287
  });
212
288
  }
289
+ async activateMainConversation(conversationId) {
290
+ const id = conversationId === null ? null : ConversationIdSchema.parse(conversationId);
291
+ const main = this.taskFromId("main");
292
+ await ensureDir(main.dir);
293
+ const lease = await this.claimTaskRunLease(main.dir);
294
+ return runWithLeaseFinalization("Main conversation restore", lease, async () => {
295
+ const conversations = await this.listMainConversations(500);
296
+ const target = conversations.find((conversation) => conversation.id === id);
297
+ if (!target) {
298
+ throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
299
+ }
300
+ const current = await this.readMainConversationState();
301
+ const currentId = current?.id ?? null;
302
+ if (currentId === id) {
303
+ return { conversation: target, restoredNativeSessions: 0, changed: false };
304
+ }
305
+ const currentSummary = conversations.find((conversation) => conversation.current);
306
+ const activatedAt = this.now().toISOString();
307
+ await this.archiveAndRetireMainConversation(main, currentId, currentSummary?.createdAt ?? current?.created_at ?? activatedAt, "switch Main conversation", Boolean(current) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
308
+ if (id) {
309
+ await writeJson(join(main.dir, "conversation.json"), MainConversationStateSchema.parse({
310
+ version: 1,
311
+ id,
312
+ created_at: target.createdAt,
313
+ ...(current?.id ? { previous_id: current.id } : {})
314
+ }));
315
+ }
316
+ else {
317
+ await removeIfExists(join(main.dir, "conversation.json"));
318
+ }
319
+ await this.writeMainConversationArchive({
320
+ version: 1,
321
+ id,
322
+ created_at: target.createdAt,
323
+ last_activated_at: activatedAt
324
+ });
325
+ const restoredNativeSessions = await this.restoreMainConversationNativeSessions(main, id);
326
+ await this.appendEvent(main, "main.conversation_restored", `Restored ${id ?? "legacy conversation"}; ${restoredNativeSessions} native session(s)`);
327
+ const restored = (await this.listMainConversations(500)).find((conversation) => conversation.id === id);
328
+ if (!restored) {
329
+ throw new Error(`Restored Main conversation disappeared from its catalog: ${id ?? "legacy"}`);
330
+ }
331
+ return { conversation: restored, restoredNativeSessions, changed: true };
332
+ });
333
+ }
213
334
  async reconcileInterruptedMainSession() {
214
335
  const main = this.taskFromId("main");
215
336
  if (!(await pathExists(main.dir))) {
@@ -803,6 +924,127 @@ export class SessionManager {
803
924
  await this.clearWorkerStatusNativeSession(worker);
804
925
  await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
805
926
  }
927
+ mainConversationArchiveDir(conversationId) {
928
+ return join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY, mainConversationScopeKey(conversationId));
929
+ }
930
+ async readMainConversationArchives() {
931
+ const root = join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY);
932
+ if (!(await pathExists(root))) {
933
+ return [];
934
+ }
935
+ const archives = [];
936
+ for (const entry of await readdir(root, { withFileTypes: true })) {
937
+ if (!entry.isDirectory()) {
938
+ continue;
939
+ }
940
+ const metadata = await readMainConversationArchiveIfValid(join(root, entry.name, "meta.json"));
941
+ if (metadata && mainConversationScopeKey(metadata.id) === entry.name) {
942
+ archives.push(metadata);
943
+ }
944
+ }
945
+ return archives;
946
+ }
947
+ async writeMainConversationArchive(metadata) {
948
+ const parsed = MainConversationArchiveSchema.parse(metadata);
949
+ const path = join(this.mainConversationArchiveDir(parsed.id), "meta.json");
950
+ const existing = await readMainConversationArchiveIfValid(path);
951
+ await writeJson(path, MainConversationArchiveSchema.parse({
952
+ ...parsed,
953
+ created_at: existing && existing.created_at < parsed.created_at
954
+ ? existing.created_at
955
+ : parsed.created_at
956
+ }));
957
+ }
958
+ async archiveAndRetireMainConversation(main, conversationId, createdAt, reason, persistEmpty = true) {
959
+ const activatedAt = this.now().toISOString();
960
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
961
+ const entries = await readdir(main.dir, { withFileTypes: true });
962
+ let archivedNativeSession = false;
963
+ for (const entry of entries) {
964
+ if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
965
+ continue;
966
+ }
967
+ const worker = { dir: join(main.dir, entry.name) };
968
+ const nativeSession = await this.readNativeSession(worker);
969
+ if (nativeSession?.scope !== "main"
970
+ || nativeSession.role !== "main"
971
+ || !safeMainWorkerId(nativeSession.worker_id)) {
972
+ continue;
973
+ }
974
+ await writeJson(join(archiveDir, `${nativeSession.worker_id}.json`), nativeSession);
975
+ await this.retireNativeSession(worker, reason);
976
+ archivedNativeSession = true;
977
+ }
978
+ if (persistEmpty || archivedNativeSession) {
979
+ await this.writeMainConversationArchive({
980
+ version: 1,
981
+ id: conversationId,
982
+ created_at: createdAt,
983
+ last_activated_at: activatedAt
984
+ });
985
+ }
986
+ }
987
+ async restoreMainConversationNativeSessions(main, conversationId) {
988
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
989
+ if (!(await pathExists(archiveDir))) {
990
+ return 0;
991
+ }
992
+ let restored = 0;
993
+ for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
994
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
995
+ continue;
996
+ }
997
+ const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
998
+ if (!record
999
+ || record.scope !== "main"
1000
+ || record.role !== "main"
1001
+ || !safeMainWorkerId(record.worker_id)
1002
+ || entry.name !== `${record.worker_id}.json`) {
1003
+ continue;
1004
+ }
1005
+ const worker = { dir: join(main.dir, record.worker_id) };
1006
+ await ensureDir(worker.dir);
1007
+ await writeJson(join(worker.dir, "native-session.json"), record);
1008
+ await removeIfExists(join(worker.dir, "native-session.retired.json"));
1009
+ await this.syncNativeSessionProjection(worker, record);
1010
+ restored += 1;
1011
+ }
1012
+ return restored;
1013
+ }
1014
+ async archivedMainNativeSessionIds(conversationId) {
1015
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
1016
+ const ids = new Set();
1017
+ if (!(await pathExists(archiveDir))) {
1018
+ return ids;
1019
+ }
1020
+ for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
1021
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
1022
+ continue;
1023
+ }
1024
+ const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
1025
+ if (record?.scope === "main" && record.role === "main") {
1026
+ ids.add(`${record.engine}\u0000${record.session_id}`);
1027
+ }
1028
+ }
1029
+ return ids;
1030
+ }
1031
+ async activeMainNativeSessionIds() {
1032
+ const ids = new Set();
1033
+ const mainDir = this.mainSessionDir();
1034
+ if (!(await pathExists(mainDir))) {
1035
+ return ids;
1036
+ }
1037
+ for (const entry of await readdir(mainDir, { withFileTypes: true })) {
1038
+ if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
1039
+ continue;
1040
+ }
1041
+ const record = await this.readNativeSession({ dir: join(mainDir, entry.name) });
1042
+ if (record?.scope === "main" && record.role === "main") {
1043
+ ids.add(`${record.engine}\u0000${record.session_id}`);
1044
+ }
1045
+ }
1046
+ return ids;
1047
+ }
806
1048
  async appendEvent(task, type, message) {
807
1049
  const event = {
808
1050
  time: this.now().toISOString(),
@@ -1375,6 +1617,26 @@ function nextConversationId(baseId, previousId) {
1375
1617
  ? `${baseId}-${suffix + 1}`
1376
1618
  : `${baseId}-2`;
1377
1619
  }
1620
+ function mainConversationScopeKey(conversationId) {
1621
+ return conversationId ?? LEGACY_MAIN_CONVERSATION_DIRECTORY;
1622
+ }
1623
+ function mainConversationTitle(text, conversationId) {
1624
+ const sanitized = sanitizePersistedMainMessage("user", text)
1625
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
1626
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1627
+ .replace(/\s+/g, " ")
1628
+ .trim();
1629
+ if (!sanitized) {
1630
+ return conversationId ? "Untitled conversation" : "Legacy conversation";
1631
+ }
1632
+ const characters = Array.from(sanitized);
1633
+ return characters.length > 80
1634
+ ? `${characters.slice(0, 77).join("")}...`
1635
+ : sanitized;
1636
+ }
1637
+ function safeMainWorkerId(workerId) {
1638
+ return SAFE_MAIN_WORKER_ID.test(workerId) && workerId !== "." && workerId !== "..";
1639
+ }
1378
1640
  function normalizeTaskTitle(title) {
1379
1641
  const normalized = title
1380
1642
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
@@ -1486,6 +1748,17 @@ async function readRetiredNativeSessionIfValid(retiredSessionPath) {
1486
1748
  return null;
1487
1749
  }
1488
1750
  }
1751
+ async function readMainConversationArchiveIfValid(path) {
1752
+ if (!(await pathExists(path))) {
1753
+ return null;
1754
+ }
1755
+ try {
1756
+ return await readJson(path, MainConversationArchiveSchema);
1757
+ }
1758
+ catch {
1759
+ return null;
1760
+ }
1761
+ }
1489
1762
  async function readWorkerStatusIfValid(statusPath) {
1490
1763
  if (!(await pathExists(statusPath))) {
1491
1764
  return null;
@@ -205,6 +205,12 @@ export const MainConversationStateSchema = z.object({
205
205
  created_at: z.string().datetime(),
206
206
  previous_id: ConversationIdSchema.optional()
207
207
  });
208
+ export const MainConversationArchiveSchema = z.object({
209
+ version: z.literal(1),
210
+ id: ConversationIdSchema.nullable(),
211
+ created_at: z.string().datetime(),
212
+ last_activated_at: z.string().datetime()
213
+ });
208
214
  export const ChatRecordSchema = z.object({
209
215
  time: z.string().datetime(),
210
216
  from: z.enum(["user", "system"]),
package/dist/tui/App.js CHANGED
@@ -29,6 +29,7 @@ import { moveWorkerSelection, WorkerOverviewView } from "./WorkerOverviewView.js
29
29
  import { FeatureBoardView, moveFeatureBoardSelection } from "./FeatureBoardView.js";
30
30
  import { CollaborationTimelineView, collaborationSelectionScrollOffset, collaborationTimelineEvents, moveCollaborationEventSelection, nextCollaborationFeatureIndex } from "./CollaborationTimelineView.js";
31
31
  import { moveTaskSessionSelection, TaskSessionsView } from "./TaskSessionsView.js";
32
+ import { MainConversationsView, moveMainConversationSelection } from "./MainConversationsView.js";
32
33
  import { moveTaskSessionDetailSelection, TaskSessionDetailView } from "./TaskSessionDetailView.js";
33
34
  import { latestTaskResultMessageIndex, parseTaskResultSummary } from "./task-result.js";
34
35
  import { buildNativeAttachLaunch, buildNativeForkLaunch, startNativeAttachProcess } from "../workers/native-attach.js";
@@ -49,7 +50,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
49
50
  errorOffsets: [],
50
51
  diffOffsets: []
51
52
  };
52
- export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, reloadConfig, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, startMainConversation, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
53
+ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, reloadConfig, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadMainConversations, activateMainConversation, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, startMainConversation, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
53
54
  configureTuiTheme({
54
55
  theme: config.ui.theme,
55
56
  colors: config.ui.colors
@@ -97,6 +98,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
97
98
  const [routerMaxScrollOffset, setRouterMaxScrollOffset] = useState(0);
98
99
  const [taskSessions, setTaskSessions] = useState([]);
99
100
  const [selectedTaskSessionIndex, setSelectedTaskSessionIndex] = useState(0);
101
+ const [sessionCenterMode, setSessionCenterMode] = useState("tasks");
102
+ const [mainConversations, setMainConversations] = useState([]);
103
+ const [selectedMainConversationIndex, setSelectedMainConversationIndex] = useState(0);
100
104
  const [taskSessionsLoading, setTaskSessionsLoading] = useState(false);
101
105
  const [taskSessionsError, setTaskSessionsError] = useState(null);
102
106
  const [taskSessionsNotice, setTaskSessionsNotice] = useState(null);
@@ -149,6 +153,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
149
153
  const routerMaxScrollOffsetRef = useRef(routerMaxScrollOffset);
150
154
  const taskSessionsRef = useRef(taskSessions);
151
155
  const selectedTaskSessionIndexRef = useRef(selectedTaskSessionIndex);
156
+ const sessionCenterModeRef = useRef("tasks");
157
+ const mainConversationsRef = useRef(mainConversations);
158
+ const selectedMainConversationIndexRef = useRef(selectedMainConversationIndex);
152
159
  const taskSessionsLoadingRef = useRef(taskSessionsLoading);
153
160
  const taskSessionsIncludeArchivedRef = useRef(taskSessionsIncludeArchived);
154
161
  const taskSessionActionRef = useRef(taskSessionAction);
@@ -180,6 +187,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
180
187
  const refreshCollaborationTimelineRef = useRef(refreshCollaborationTimeline);
181
188
  const activateSelectedTaskSessionRef = useRef(activateSelectedTaskSession);
182
189
  const refreshTaskSessionsRef = useRef(refreshTaskSessions);
190
+ const openMainConversationsRef = useRef(openMainConversations);
191
+ const restoreSelectedMainConversationRef = useRef(restoreSelectedMainConversation);
183
192
  const renameSelectedTaskSessionRef = useRef(renameSelectedTaskSession);
184
193
  const archiveSelectedTaskSessionRef = useRef(archiveSelectedTaskSession);
185
194
  const deleteSelectedTaskSessionRef = useRef(deleteSelectedTaskSession);
@@ -334,6 +343,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
334
343
  useEffect(() => {
335
344
  selectedTaskSessionIndexRef.current = selectedTaskSessionIndex;
336
345
  }, [selectedTaskSessionIndex]);
346
+ useEffect(() => {
347
+ sessionCenterModeRef.current = sessionCenterMode;
348
+ }, [sessionCenterMode]);
349
+ useEffect(() => {
350
+ mainConversationsRef.current = mainConversations;
351
+ }, [mainConversations]);
352
+ useEffect(() => {
353
+ selectedMainConversationIndexRef.current = selectedMainConversationIndex;
354
+ }, [selectedMainConversationIndex]);
337
355
  useEffect(() => {
338
356
  taskSessionsLoadingRef.current = taskSessionsLoading;
339
357
  }, [taskSessionsLoading]);
@@ -405,6 +423,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
405
423
  refreshCollaborationTimelineRef.current = refreshCollaborationTimeline;
406
424
  activateSelectedTaskSessionRef.current = activateSelectedTaskSession;
407
425
  refreshTaskSessionsRef.current = refreshTaskSessions;
426
+ openMainConversationsRef.current = openMainConversations;
427
+ restoreSelectedMainConversationRef.current = restoreSelectedMainConversation;
408
428
  renameSelectedTaskSessionRef.current = renameSelectedTaskSession;
409
429
  archiveSelectedTaskSessionRef.current = archiveSelectedTaskSession;
410
430
  deleteSelectedTaskSessionRef.current = deleteSelectedTaskSession;
@@ -685,6 +705,13 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
685
705
  setTaskSessionsError(null);
686
706
  setSelectedTaskSessionIndex(nextIndex);
687
707
  };
708
+ const moveSelectedMainConversation = (delta, wrap = false) => {
709
+ const nextIndex = moveMainConversationSelection(selectedMainConversationIndexRef.current, delta, mainConversationsRef.current.length, wrap);
710
+ selectedMainConversationIndexRef.current = nextIndex;
711
+ setTaskSessionsError(null);
712
+ setTaskSessionsNotice(null);
713
+ setSelectedMainConversationIndex(nextIndex);
714
+ };
688
715
  const moveSelectedTaskSessionDetailWorker = (delta, wrap = false) => {
689
716
  const nextIndex = moveTaskSessionDetailSelection(taskSessionDetailSelectedWorkerIndexRef.current, delta, taskSessionDetailsRef.current?.workers.length ?? 0, wrap);
690
717
  taskSessionDetailSelectedWorkerIndexRef.current = nextIndex;
@@ -847,6 +874,49 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
847
874
  }
848
875
  return;
849
876
  }
877
+ if (sessionCenterModeRef.current === "conversations") {
878
+ if (isTaskSessionsShortcut(chunk, {}) || chunk === "\x1b") {
879
+ setTaskSessionsError(null);
880
+ setTaskSessionsNotice(null);
881
+ viewRef.current = taskSessionsReturnViewRef.current;
882
+ setView(taskSessionsReturnViewRef.current);
883
+ return;
884
+ }
885
+ if (taskSessionsLoadingRef.current) {
886
+ return;
887
+ }
888
+ if (isNewConversationShortcut(chunk, {}) || chunk === "n" || chunk === "N") {
889
+ void newConversationRef.current();
890
+ return;
891
+ }
892
+ if (chunk === "t" || chunk === "T") {
893
+ sessionCenterModeRef.current = "tasks";
894
+ setSessionCenterMode("tasks");
895
+ setTaskSessionsError(null);
896
+ setTaskSessionsNotice(null);
897
+ void refreshTaskSessionsRef.current(activeTaskIdRef.current);
898
+ return;
899
+ }
900
+ if (chunk === "c" || chunk === "C") {
901
+ void openMainConversationsRef.current();
902
+ return;
903
+ }
904
+ if (chunk === "\r" || chunk === "\n") {
905
+ void restoreSelectedMainConversationRef.current();
906
+ return;
907
+ }
908
+ if (chunk === "\t") {
909
+ moveSelectedMainConversation(1, true);
910
+ return;
911
+ }
912
+ const conversationSelectionDelta = -(rawHistoryDelta(chunk)
913
+ + rawPageScrollDelta(chunk, Math.max(1, outputHeight - 2))
914
+ + mouseScrollDelta(chunk, 1));
915
+ if (conversationSelectionDelta !== 0) {
916
+ moveSelectedMainConversation(conversationSelectionDelta);
917
+ }
918
+ return;
919
+ }
850
920
  const sessionAction = taskSessionActionRef.current;
851
921
  if (sessionAction) {
852
922
  if (chunk === "\x1b") {
@@ -902,6 +972,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
902
972
  void newConversationRef.current();
903
973
  return;
904
974
  }
975
+ if (chunk === "c" || chunk === "C") {
976
+ void openMainConversationsRef.current();
977
+ return;
978
+ }
905
979
  const selectedSession = taskSessionsRef.current[selectedTaskSessionIndexRef.current];
906
980
  if (chunk === "r" || chunk === "R") {
907
981
  if (selectedSession) {
@@ -1682,7 +1756,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1682
1756
  label: "sessions",
1683
1757
  text: details
1684
1758
  ? details.workers.map((worker) => [worker.id, worker.role, worker.engine, worker.state].join(" · ")).join("\n")
1685
- : taskSessionsRef.current.map((task) => [task.id, task.title, task.status].join(" · ")).join("\n")
1759
+ : sessionCenterModeRef.current === "conversations"
1760
+ ? mainConversationsRef.current.map((conversation) => [
1761
+ conversation.current ? "current" : "saved",
1762
+ conversation.id ?? "legacy",
1763
+ conversation.title,
1764
+ `${conversation.messageCount} messages`,
1765
+ `${conversation.nativeSessionCount} native`
1766
+ ].join(" · ")).join("\n")
1767
+ : taskSessionsRef.current.map((task) => [task.id, task.title, task.status].join(" · ")).join("\n")
1686
1768
  };
1687
1769
  }
1688
1770
  if (currentView === "router") {
@@ -2110,6 +2192,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2110
2192
  setAttachError(error instanceof Error ? error.message : String(error));
2111
2193
  return;
2112
2194
  }
2195
+ resetActiveTaskUiForMainConversation();
2196
+ await appendVisibleMessage({ from: "system", text: "new conversation · ready" });
2197
+ }
2198
+ function resetActiveTaskUiForMainConversation() {
2113
2199
  const nextMemory = newTaskMemoryState();
2114
2200
  activeTaskIdRef.current = nextMemory.activeTaskId;
2115
2201
  setActiveTaskId(nextMemory.activeTaskId);
@@ -2134,7 +2220,6 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2134
2220
  offset: 0,
2135
2221
  draft: { value: inputRef.current, cursor: inputCursorRef.current }
2136
2222
  };
2137
- await appendVisibleMessage({ from: "system", text: "new conversation · ready" });
2138
2223
  }
2139
2224
  async function openRouterDiagnostics() {
2140
2225
  const currentView = viewRef.current;
@@ -2200,6 +2285,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2200
2285
  setTaskSessionDetailSelectedWorkerIndex(0);
2201
2286
  setTaskSessionDetailNotice(null);
2202
2287
  updateTaskSessionAction(null);
2288
+ sessionCenterModeRef.current = "tasks";
2289
+ setSessionCenterMode("tasks");
2203
2290
  await refreshTaskSessions(activeTaskIdRef.current);
2204
2291
  }
2205
2292
  async function refreshTaskSessions(preferredTaskId = null) {
@@ -2240,6 +2327,103 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2240
2327
  }
2241
2328
  }
2242
2329
  }
2330
+ async function openMainConversations() {
2331
+ const currentView = viewRef.current;
2332
+ if (busyRef.current || currentView === "native" || currentView === "workspace") {
2333
+ return;
2334
+ }
2335
+ if (currentView !== "sessions") {
2336
+ taskSessionsReturnViewRef.current = currentView === "worker" || currentView === "workers" || currentView === "router"
2337
+ ? currentView
2338
+ : "chat";
2339
+ viewRef.current = "sessions";
2340
+ setView("sessions");
2341
+ }
2342
+ const previousId = sessionCenterModeRef.current === "conversations"
2343
+ ? mainConversationsRef.current[selectedMainConversationIndexRef.current]?.id
2344
+ : undefined;
2345
+ sessionCenterModeRef.current = "conversations";
2346
+ setSessionCenterMode("conversations");
2347
+ taskSessionDetailsRef.current = null;
2348
+ setTaskSessionDetails(null);
2349
+ updateTaskSessionAction(null);
2350
+ setTaskSessionsError(null);
2351
+ setTaskSessionsNotice(null);
2352
+ taskSessionsLoadingRef.current = true;
2353
+ setTaskSessionsLoading(true);
2354
+ const sequence = taskSessionsLoadSequenceRef.current + 1;
2355
+ taskSessionsLoadSequenceRef.current = sequence;
2356
+ try {
2357
+ if (!loadMainConversations) {
2358
+ throw new Error("Main conversations are unavailable");
2359
+ }
2360
+ const conversations = await loadMainConversations();
2361
+ if (taskSessionsLoadSequenceRef.current !== sequence) {
2362
+ return;
2363
+ }
2364
+ mainConversationsRef.current = conversations;
2365
+ setMainConversations(conversations);
2366
+ const preservedIndex = previousId !== undefined
2367
+ ? conversations.findIndex((conversation) => conversation.id === previousId)
2368
+ : -1;
2369
+ const currentIndex = conversations.findIndex((conversation) => conversation.current);
2370
+ const selectedIndex = preservedIndex >= 0
2371
+ ? preservedIndex
2372
+ : currentIndex >= 0
2373
+ ? currentIndex
2374
+ : 0;
2375
+ selectedMainConversationIndexRef.current = selectedIndex;
2376
+ setSelectedMainConversationIndex(selectedIndex);
2377
+ }
2378
+ catch (error) {
2379
+ if (taskSessionsLoadSequenceRef.current === sequence) {
2380
+ setTaskSessionsError(error instanceof Error ? error.message : String(error));
2381
+ }
2382
+ }
2383
+ finally {
2384
+ if (taskSessionsLoadSequenceRef.current === sequence) {
2385
+ taskSessionsLoadingRef.current = false;
2386
+ setTaskSessionsLoading(false);
2387
+ }
2388
+ }
2389
+ }
2390
+ async function restoreSelectedMainConversation() {
2391
+ if (busyRef.current || taskSessionsLoadingRef.current) {
2392
+ return;
2393
+ }
2394
+ const selected = mainConversationsRef.current[selectedMainConversationIndexRef.current];
2395
+ if (!selected) {
2396
+ return;
2397
+ }
2398
+ taskSessionsLoadingRef.current = true;
2399
+ setTaskSessionsLoading(true);
2400
+ setTaskSessionsError(null);
2401
+ setTaskSessionsNotice(null);
2402
+ try {
2403
+ if (!activateMainConversation) {
2404
+ throw new Error("Main conversation restore is unavailable");
2405
+ }
2406
+ const result = await activateMainConversation(selected.id);
2407
+ if (!result.changed) {
2408
+ await openMainConversations();
2409
+ setTaskSessionsNotice(`Already current · ${result.conversation.title}`);
2410
+ return;
2411
+ }
2412
+ await activateTaskSession?.(null);
2413
+ resetActiveTaskUiForMainConversation();
2414
+ await appendVisibleMessage({
2415
+ from: "system",
2416
+ text: `conversation restored · ${result.conversation.title} · ${result.restoredNativeSessions} native`
2417
+ });
2418
+ }
2419
+ catch (error) {
2420
+ setTaskSessionsError(error instanceof Error ? error.message : String(error));
2421
+ }
2422
+ finally {
2423
+ taskSessionsLoadingRef.current = false;
2424
+ setTaskSessionsLoading(false);
2425
+ }
2426
+ }
2243
2427
  function updateTaskSessionAction(next) {
2244
2428
  taskSessionActionRef.current = next;
2245
2429
  setTaskSessionAction(next);
@@ -2727,11 +2911,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2727
2911
  ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
2728
2912
  : taskSessionAction?.type === "delete"
2729
2913
  ? { type: "delete", title: taskSessionAction.title }
2730
- : null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
2914
+ : null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, mainConversationSessions: sessionCenterMode === "conversations" && !taskSessionDetails, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
2731
2915
  ? workerSearch.query
2732
2916
  : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
2733
2917
  ? workerSearch.cursor
2734
- : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2918
+ : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : sessionCenterMode === "conversations" ? (_jsx(MainConversationsView, { conversations: mainConversations, selectedIndex: selectedMainConversationIndex, notice: taskSessionsNotice, loading: taskSessionsLoading, error: taskSessionsError, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2735
2919
  ? { type: "rename", title: taskSessionAction.title }
2736
2920
  : taskSessionAction?.type === "delete"
2737
2921
  ? { type: "delete", title: taskSessionAction.title }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import { compactEndByDisplayWidth, compactTailByDisplayWidth, displayWidth } from "./display-width.js";
4
4
  import { TUI_THEME } from "./theme.js";
5
- export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
5
+ export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, mainConversationSessions = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
6
6
  const terminalWidth = providedTerminalWidth ?? process.stdout.columns ?? 120;
7
7
  const fillRail = providedTerminalWidth !== undefined || typeof process.stdout.columns === "number";
8
8
  if (clipboardNotice) {
@@ -39,6 +39,10 @@ export function InputBar({ mode, ready = true, busy = false, routeFallback = fal
39
39
  const hints = taskSessionDetailInputHints(terminalWidth, taskSessionDetailHasNative, taskSessionDetailCanFork);
40
40
  return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
41
41
  }
42
+ if (mainConversationSessions) {
43
+ const hints = mainConversationSessionsInputHints(terminalWidth);
44
+ return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
45
+ }
42
46
  const hints = taskSessionsInputHints(terminalWidth, taskSessionsIncludeArchived);
43
47
  return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
44
48
  }
@@ -564,9 +568,13 @@ function collaborationDetailInputHints(width) {
564
568
  function taskSessionsInputHints(width, includeArchived) {
565
569
  const archivedAction = includeArchived ? "H hide archived" : "H archived";
566
570
  return selectInputHints(width, [
567
- { label: "sessions", detail: ` · Up/Dn select · Enter restore · I inspect · R rename · A archive · D delete · E export · ${archivedAction} · Esc back` },
568
- { label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · A archive · D delete · E export · Esc back" },
569
- { label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · A archive · D delete · Esc back" },
571
+ { label: "sessions", detail: ` · Up/Dn select · Enter restore · C conversations · I inspect · R rename · A archive · D delete · E export · ${archivedAction} · Esc back` },
572
+ { label: "sessions", detail: " · Up/Dn select · Enter restore · C conversations · I inspect · R rename · A archive · D delete · E export · Esc back" },
573
+ { label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · I inspect · R rename · A archive · D delete · Esc back" },
574
+ { label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · I inspect · R rename · A archive · Esc back" },
575
+ { label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · I inspect · R rename · Esc back" },
576
+ { label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · R rename · Esc back" },
577
+ { label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · Esc back" },
570
578
  { label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · A archive · Esc back" },
571
579
  { label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · Esc back" },
572
580
  { label: "sessions", detail: " · Up/Dn select · Enter restore · R rename · Esc back" },
@@ -580,6 +588,18 @@ function taskSessionsInputHints(width, includeArchived) {
580
588
  { label: "s", detail: "" }
581
589
  ]);
582
590
  }
591
+ function mainConversationSessionsInputHints(width) {
592
+ return selectInputHints(width, [
593
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · N new · T tasks · Esc back" },
594
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · T tasks · Esc back" },
595
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · Esc back" },
596
+ { label: "conversations", detail: " · Up/Dn select · Esc back" },
597
+ { label: "conversations", detail: " · Esc back" },
598
+ { label: "Esc back", detail: "" },
599
+ { label: "chats", detail: "" },
600
+ { label: "c", detail: "" }
601
+ ]);
602
+ }
583
603
  function taskSessionDetailInputHints(width, hasNative, canFork) {
584
604
  const nativeActions = hasNative
585
605
  ? `${canFork ? "C continue · B branch" : "C continue"} · `
@@ -0,0 +1,142 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { compactEndByDisplayWidth, displayWidth } from "./display-width.js";
4
+ import { TUI_THEME } from "./theme.js";
5
+ export function MainConversationsView({ conversations, selectedIndex, notice = null, loading = false, error = null, height = 20, terminalWidth = process.stdout.columns || 120 }) {
6
+ const viewportHeight = Math.max(1, height);
7
+ const width = mainConversationsContentWidth(terminalWidth);
8
+ const lines = mainConversationsDisplayLines(conversations, selectedIndex, viewportHeight, terminalWidth, { notice, loading, error });
9
+ const blankRows = Math.max(0, viewportHeight - lines.length);
10
+ return (_jsxs(Box, { flexDirection: "column", height: viewportHeight, children: [lines.map((line, index) => (_jsx(MainConversationRow, { line: line, width: width }, `${line.conversationIndex ?? line.tone}-${index}`))), Array.from({ length: blankRows }, (_, index) => (_jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(width) }, `main-conversation-fill-${index}`)))] }));
11
+ }
12
+ export function mainConversationsDisplayLines(conversations, selectedIndex, height, terminalWidth, state = {}) {
13
+ const viewportHeight = Math.max(1, Math.trunc(height));
14
+ const width = mainConversationsContentWidth(terminalWidth);
15
+ const lines = [{
16
+ text: fitMainConversationCandidates(["Main conversations", "Conversations", "Chats", "C"], width),
17
+ tone: "heading"
18
+ }];
19
+ if (viewportHeight >= 3) {
20
+ const messages = conversations.reduce((sum, conversation) => sum + conversation.messageCount, 0);
21
+ const nativeSessions = conversations.reduce((sum, conversation) => sum + conversation.nativeSessionCount, 0);
22
+ lines.push({
23
+ text: fitMainConversationCandidates([
24
+ `${conversations.length} ${conversations.length === 1 ? "conversation" : "conversations"} · ${messages} messages · ${nativeSessions} native`,
25
+ `${conversations.length} conversations · ${messages} messages`,
26
+ `${conversations.length} conversations`,
27
+ `${conversations.length} chats`
28
+ ], width),
29
+ tone: "muted"
30
+ });
31
+ }
32
+ if (viewportHeight >= 4 && state.notice) {
33
+ lines.push({ text: fitMainConversationText(state.notice, width), tone: "success" });
34
+ }
35
+ const slots = Math.max(0, viewportHeight - lines.length);
36
+ if (state.loading) {
37
+ if (slots > 0) {
38
+ lines.push({ text: fitMainConversationText("loading Main conversations", width), tone: "muted" });
39
+ }
40
+ return lines;
41
+ }
42
+ if (state.error) {
43
+ if (slots > 0) {
44
+ lines.push({ text: fitMainConversationText(`error · ${safeMainConversationText(state.error)}`, width), tone: "danger" });
45
+ }
46
+ return lines;
47
+ }
48
+ if (conversations.length === 0) {
49
+ if (slots > 0) {
50
+ lines.push({ text: fitMainConversationText("No saved Main conversations", width), tone: "muted" });
51
+ }
52
+ return lines;
53
+ }
54
+ const selected = clampMainConversationIndex(selectedIndex, conversations.length);
55
+ const visibleCount = Math.min(slots, conversations.length);
56
+ const start = mainConversationWindowStart(selected, conversations.length, visibleCount);
57
+ for (let index = start; index < start + visibleCount; index += 1) {
58
+ const conversation = conversations[index];
59
+ if (!conversation) {
60
+ continue;
61
+ }
62
+ lines.push({
63
+ text: mainConversationRowText(conversation, index === selected, width),
64
+ tone: conversation.current ? "success" : index === selected ? "active" : "muted",
65
+ conversationIndex: index
66
+ });
67
+ }
68
+ return lines;
69
+ }
70
+ export function moveMainConversationSelection(current, delta, conversationCount, wrap = false) {
71
+ if (conversationCount <= 0) {
72
+ return 0;
73
+ }
74
+ const normalizedCurrent = clampMainConversationIndex(current, conversationCount);
75
+ const next = normalizedCurrent + Math.trunc(delta);
76
+ if (wrap) {
77
+ return ((next % conversationCount) + conversationCount) % conversationCount;
78
+ }
79
+ return Math.min(conversationCount - 1, Math.max(0, next));
80
+ }
81
+ function MainConversationRow({ line, width }) {
82
+ const trailingWidth = Math.max(0, width - displayWidth(line.text));
83
+ const theme = mainConversationLineTheme(line.tone);
84
+ return (_jsxs(Text, { children: [_jsx(Text, { ...theme, children: line.text }), trailingWidth > 0 ? _jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(trailingWidth) }) : null] }));
85
+ }
86
+ function mainConversationRowText(conversation, selected, width) {
87
+ const marker = `${selected ? ">" : " "} ${conversation.current ? "*" : " "} `;
88
+ const title = safeMainConversationText(conversation.title);
89
+ const messages = `${conversation.messageCount} ${conversation.messageCount === 1 ? "message" : "messages"}`;
90
+ const native = `${conversation.nativeSessionCount} native`;
91
+ const date = conversation.lastActivityAt.slice(5, 16).replace("T", " ");
92
+ const scope = conversation.id
93
+ ? `#${conversation.id.replace(/^conversation-/, "")}`
94
+ : "legacy";
95
+ return fitMainConversationCandidates([
96
+ [marker + title, messages, native, date].join(" · "),
97
+ [marker + title, messages, native].join(" · "),
98
+ [marker + title, messages].join(" · "),
99
+ [marker + title, date].join(" · "),
100
+ [marker + scope, messages].join(" · "),
101
+ marker.trimEnd()
102
+ ], width);
103
+ }
104
+ function mainConversationWindowStart(selected, count, visibleCount) {
105
+ if (visibleCount <= 0 || count <= visibleCount) {
106
+ return 0;
107
+ }
108
+ return Math.min(count - visibleCount, Math.max(0, selected - Math.floor(visibleCount / 2)));
109
+ }
110
+ function clampMainConversationIndex(index, count) {
111
+ return Math.min(Math.max(0, count - 1), Math.max(0, Math.trunc(index)));
112
+ }
113
+ function fitMainConversationCandidates(candidates, width) {
114
+ return candidates.find((candidate) => displayWidth(candidate) <= width)
115
+ ?? fitMainConversationText(candidates.at(-1) ?? "", width);
116
+ }
117
+ function fitMainConversationText(text, width) {
118
+ return compactEndByDisplayWidth(text, Math.max(1, width));
119
+ }
120
+ function safeMainConversationText(text) {
121
+ return text
122
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
123
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
124
+ .replace(/\s+/g, " ")
125
+ .trim();
126
+ }
127
+ function mainConversationLineTheme(tone) {
128
+ return {
129
+ backgroundColor: TUI_THEME.surface,
130
+ color: tone === "heading" || tone === "active"
131
+ ? TUI_THEME.accent
132
+ : tone === "success"
133
+ ? TUI_THEME.success
134
+ : tone === "danger"
135
+ ? TUI_THEME.danger
136
+ : TUI_THEME.muted,
137
+ ...(tone === "heading" || tone === "danger" ? { bold: true } : {})
138
+ };
139
+ }
140
+ function mainConversationsContentWidth(terminalWidth) {
141
+ return Math.max(1, terminalWidth - 2);
142
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.2.8";
1
+ export const version = "0.2.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",