parallel-codex-tui 0.2.9 → 0.2.10

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.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.
9
+ `v0.2.10` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.10) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.10). `Ctrl+T`, then `C`, opens every persisted Main conversation and now provides the same durable management lifecycle as Task sessions: rename, archive, unarchive, scoped export, and confirmed deletion. 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
 
@@ -25,7 +25,7 @@ Highlights:
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
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,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`.
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,311 tests across 130 files: 1,310 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
 
@@ -469,7 +469,7 @@ From Worker overview, press `C` to open the file-backed Actor/Critic collaborati
469
469
 
470
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
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.
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, `R` renames it with Unicode-safe cursor editing, `A` archives or unarchives it, `D` twice confirms deletion, `E` exports it, `H` shows or hides archived conversations, `N` starts a new one, `T` returns to Tasks, and `Esc` returns to chat. Archived conversations are hidden by default and cannot be restored until unarchived; the current Main conversation cannot be archived or deleted. A Main export is a versioned, conversation-scoped snapshot under `.parallel-codex/exports/<conversation>-<timestamp>/` containing only that conversation's chat rows and matching native sessions. Confirmed deletion streams the shared chat JSONL into an atomic replacement, preserves Task chat and malformed evidence, and restores the original chat and conversation archive if publication fails. 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.
473
473
 
474
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.
475
475
 
@@ -523,12 +523,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
523
523
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
524
524
 
525
525
  ```bash
526
- VERSION=0.2.9
526
+ VERSION=0.2.10
527
527
  git tag "v$VERSION"
528
528
  git push origin "v$VERSION"
529
529
  ```
530
530
 
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.
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.10` requires tag `v0.2.10`. Published tags such as `v0.2.9` are immutable and must not be moved or reused.
532
532
 
533
533
  ## Publishing Hygiene
534
534
 
package/dist/cli.js CHANGED
@@ -127,11 +127,17 @@ async function main() {
127
127
  records,
128
128
  policy: routerDiagnosticsPolicy(latestConfig.router)
129
129
  };
130
- }, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), loadMainConversations: () => state.runtime.sessions.listMainConversations(100), activateMainConversation: async (conversationId) => {
130
+ }, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), loadMainConversations: (options) => state.runtime.sessions.listMainConversations(100, options), activateMainConversation: async (conversationId) => {
131
131
  const restored = await state.runtime.sessions.activateMainConversation(conversationId);
132
132
  await state.runtime.index.setActiveTaskId(null);
133
133
  return restored;
134
- }, loadTaskSessionDetails: (task) => loadPersistedTaskSessionDetails({
134
+ }, renameMainConversation: async (conversationId, title) => {
135
+ await state.runtime.sessions.renameMainConversation(conversationId, title);
136
+ }, setMainConversationArchived: async (conversationId, archived) => {
137
+ await state.runtime.sessions.setMainConversationArchived(conversationId, archived);
138
+ }, deleteMainConversation: async (conversationId) => {
139
+ await state.runtime.sessions.deleteMainConversation(conversationId);
140
+ }, exportMainConversation: async (conversationId) => (await state.runtime.sessions.exportMainConversation(conversationId)).path, loadTaskSessionDetails: (task) => loadPersistedTaskSessionDetails({
135
141
  task,
136
142
  taskDir: state.runtime.sessions.taskFromId(task.id).dir,
137
143
  modelNames: Object.fromEntries(Object.entries(state.runtime.config.workers).map(([id, worker]) => [id, worker.model.name]))
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { cp, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { cp, mkdir, mkdtemp, open, readdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { basename, dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline";
4
6
  import { z } from "zod";
5
7
  import { appendJsonLine, appendText, ensureDir, pathExists, readJson, readRecentJsonLines, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
6
8
  import { runWithLeaseFinalization } from "./lease-finalization.js";
@@ -183,7 +185,7 @@ export class SessionManager {
183
185
  }
184
186
  return readJson(path, MainConversationStateSchema);
185
187
  }
186
- async listMainConversations(limit = 100) {
188
+ async listMainConversations(limit = 100, options = {}) {
187
189
  const boundedLimit = Number.isFinite(limit)
188
190
  ? Math.min(500, Math.max(0, Math.trunc(limit)))
189
191
  : 100;
@@ -223,7 +225,13 @@ export class SessionManager {
223
225
  return summary;
224
226
  };
225
227
  for (const archive of archives) {
226
- ensureConversation(archive.id, archive.created_at, archive.last_activated_at);
228
+ const conversation = ensureConversation(archive.id, archive.created_at, archive.last_activated_at);
229
+ if (archive.title) {
230
+ conversation.title = archive.title;
231
+ }
232
+ if (archive.archived_at) {
233
+ conversation.archivedAt = archive.archived_at;
234
+ }
227
235
  }
228
236
  for (const record of records) {
229
237
  const id = record.conversation_id ?? null;
@@ -254,7 +262,9 @@ export class SessionManager {
254
262
  conversation.nativeSessionCount = archived.size;
255
263
  }));
256
264
  return [...conversations.values()]
265
+ .filter((conversation) => options.includeArchived || !conversation.archivedAt || conversation.current)
257
266
  .sort((left, right) => (Number(right.current) - Number(left.current)
267
+ || Number(Boolean(left.archivedAt)) - Number(Boolean(right.archivedAt))
258
268
  || right.lastActivityAt.localeCompare(left.lastActivityAt)
259
269
  || mainConversationScopeKey(right.id).localeCompare(mainConversationScopeKey(left.id))))
260
270
  .slice(0, boundedLimit);
@@ -292,11 +302,14 @@ export class SessionManager {
292
302
  await ensureDir(main.dir);
293
303
  const lease = await this.claimTaskRunLease(main.dir);
294
304
  return runWithLeaseFinalization("Main conversation restore", lease, async () => {
295
- const conversations = await this.listMainConversations(500);
305
+ const conversations = await this.listMainConversations(500, { includeArchived: true });
296
306
  const target = conversations.find((conversation) => conversation.id === id);
297
307
  if (!target) {
298
308
  throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
299
309
  }
310
+ if (target.archivedAt) {
311
+ throw new Error(`Cannot restore archived Main conversation ${id ?? "legacy"}. Unarchive it first.`);
312
+ }
300
313
  const current = await this.readMainConversationState();
301
314
  const currentId = current?.id ?? null;
302
315
  if (currentId === id) {
@@ -331,6 +344,140 @@ export class SessionManager {
331
344
  return { conversation: restored, restoredNativeSessions, changed: true };
332
345
  });
333
346
  }
347
+ async renameMainConversation(conversationId, title) {
348
+ const normalizedTitle = normalizeMainConversationTitle(title);
349
+ return this.withMainConversationManagementLease(conversationId, "rename", async (main, conversation) => {
350
+ const metadata = await this.mainConversationArchiveMetadata(conversation);
351
+ await this.writeMainConversationArchive({ ...metadata, title: normalizedTitle });
352
+ await this.appendEvent(main, "main.conversation_renamed", `Renamed ${conversation.id ?? "legacy conversation"} to ${normalizedTitle}`);
353
+ return this.requireMainConversationSummary(conversation.id);
354
+ });
355
+ }
356
+ async setMainConversationArchived(conversationId, archived) {
357
+ return this.withMainConversationManagementLease(conversationId, archived ? "archive" : "unarchive", async (main, conversation) => {
358
+ if (archived && conversation.current) {
359
+ throw new Error("Cannot archive the current Main conversation. Restore another conversation first.");
360
+ }
361
+ if (Boolean(conversation.archivedAt) === archived) {
362
+ return conversation;
363
+ }
364
+ const metadata = await this.mainConversationArchiveMetadata(conversation);
365
+ const next = archived
366
+ ? MainConversationArchiveSchema.parse({
367
+ ...metadata,
368
+ archived_at: this.now().toISOString()
369
+ })
370
+ : withoutMainConversationArchiveTime(metadata);
371
+ await writeJson(join(this.mainConversationArchiveDir(conversation.id), "meta.json"), next);
372
+ await this.appendEvent(main, archived ? "main.conversation_archived" : "main.conversation_unarchived", archived
373
+ ? `Archived ${conversation.id ?? "legacy conversation"}`
374
+ : `Unarchived ${conversation.id ?? "legacy conversation"}`);
375
+ return this.requireMainConversationSummary(conversation.id);
376
+ });
377
+ }
378
+ async deleteMainConversation(conversationId) {
379
+ await this.withMainConversationManagementLease(conversationId, "delete", async (main, conversation) => {
380
+ if (conversation.current) {
381
+ throw new Error("Cannot delete the current Main conversation. Restore another conversation first.");
382
+ }
383
+ const staging = await mkdtemp(join(main.dir, ".conversation-delete-"));
384
+ const chatPath = join(main.dir, "chat.jsonl");
385
+ const replacementPath = join(staging, "chat.next.jsonl");
386
+ const originalPath = join(staging, "chat.original.jsonl");
387
+ const archivePath = this.mainConversationArchiveDir(conversation.id);
388
+ const stagedArchivePath = join(staging, "archive");
389
+ let archiveMoved = false;
390
+ let originalMoved = false;
391
+ let replacementInstalled = false;
392
+ try {
393
+ await writeConversationChatFile(chatPath, replacementPath, conversation.id, "exclude");
394
+ if (await pathExists(archivePath)) {
395
+ await rename(archivePath, stagedArchivePath);
396
+ archiveMoved = true;
397
+ }
398
+ if (await pathExists(chatPath)) {
399
+ await rename(chatPath, originalPath);
400
+ originalMoved = true;
401
+ }
402
+ await rename(replacementPath, chatPath);
403
+ replacementInstalled = true;
404
+ await this.appendEvent(main, "main.conversation_deleted", `Deleted ${conversation.id ?? "legacy conversation"}`);
405
+ }
406
+ catch (error) {
407
+ const rollbackErrors = [];
408
+ if (replacementInstalled) {
409
+ try {
410
+ await removeIfExists(chatPath);
411
+ }
412
+ catch (rollbackError) {
413
+ rollbackErrors.push(rollbackError);
414
+ }
415
+ }
416
+ if (originalMoved) {
417
+ try {
418
+ await rename(originalPath, chatPath);
419
+ }
420
+ catch (rollbackError) {
421
+ rollbackErrors.push(rollbackError);
422
+ }
423
+ }
424
+ if (archiveMoved) {
425
+ try {
426
+ await rename(stagedArchivePath, archivePath);
427
+ }
428
+ catch (rollbackError) {
429
+ rollbackErrors.push(rollbackError);
430
+ }
431
+ }
432
+ await rm(staging, { force: true, recursive: true }).catch(() => undefined);
433
+ if (rollbackErrors.length > 0) {
434
+ throw new Error(`Main conversation deletion failed and rollback also failed: ${errorMessage(error)}`, { cause: new AggregateError([error, ...rollbackErrors]) });
435
+ }
436
+ throw error;
437
+ }
438
+ await rm(staging, { force: true, recursive: true }).catch(() => undefined);
439
+ });
440
+ }
441
+ async exportMainConversation(conversationId) {
442
+ return this.withMainConversationManagementLease(conversationId, "export", async (main, conversation) => {
443
+ const createdAt = this.now().toISOString();
444
+ const exportsRoot = join(this.projectRoot, this.dataDir, "exports");
445
+ await ensureDir(exportsRoot);
446
+ const scope = conversation.id ?? "main-legacy";
447
+ const staging = await mkdtemp(join(exportsRoot, `.${scope}-`));
448
+ const suffix = basename(staging).slice(-6);
449
+ const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
450
+ const destination = join(exportsRoot, `${scope}-${stamp}-${suffix}`);
451
+ try {
452
+ const messageCount = await writeConversationChatFile(join(main.dir, "chat.jsonl"), join(staging, "chat.jsonl"), conversation.id, "include");
453
+ const nativeSessions = await this.mainConversationNativeSessions(conversation.id, conversation.current);
454
+ for (const session of nativeSessions) {
455
+ await writeJson(join(staging, "native-sessions", `${session.worker_id}.json`), session);
456
+ }
457
+ await writeJson(join(staging, "manifest.json"), {
458
+ format: "parallel-codex-main-conversation-export-v1",
459
+ exported_at: createdAt,
460
+ source_workspace: this.projectRoot,
461
+ conversation: {
462
+ id: conversation.id,
463
+ title: conversation.title,
464
+ created_at: conversation.createdAt,
465
+ last_activity_at: conversation.lastActivityAt,
466
+ ...(conversation.archivedAt ? { archived_at: conversation.archivedAt } : {})
467
+ },
468
+ message_count: messageCount,
469
+ native_session_count: nativeSessions.length
470
+ });
471
+ await this.appendEvent(main, "main.conversation_exported", `Exported ${conversation.id ?? "legacy conversation"}`);
472
+ await rename(staging, destination);
473
+ }
474
+ catch (error) {
475
+ await rm(staging, { force: true, recursive: true });
476
+ throw error;
477
+ }
478
+ return { conversationId: conversation.id, path: destination, createdAt };
479
+ });
480
+ }
334
481
  async reconcileInterruptedMainSession() {
335
482
  const main = this.taskFromId("main");
336
483
  if (!(await pathExists(main.dir))) {
@@ -924,6 +1071,24 @@ export class SessionManager {
924
1071
  await this.clearWorkerStatusNativeSession(worker);
925
1072
  await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
926
1073
  }
1074
+ async mainConversationArchiveMetadata(conversation) {
1075
+ const existing = await readMainConversationArchiveIfValid(join(this.mainConversationArchiveDir(conversation.id), "meta.json"));
1076
+ return MainConversationArchiveSchema.parse({
1077
+ ...existing,
1078
+ version: 1,
1079
+ id: conversation.id,
1080
+ created_at: existing?.created_at ?? conversation.createdAt,
1081
+ last_activated_at: existing?.last_activated_at ?? conversation.lastActivityAt
1082
+ });
1083
+ }
1084
+ async requireMainConversationSummary(conversationId) {
1085
+ const conversation = (await this.listMainConversations(500, { includeArchived: true }))
1086
+ .find((entry) => entry.id === conversationId);
1087
+ if (!conversation) {
1088
+ throw new Error(`Main conversation disappeared from its catalog: ${conversationId ?? "legacy"}`);
1089
+ }
1090
+ return conversation;
1091
+ }
927
1092
  mainConversationArchiveDir(conversationId) {
928
1093
  return join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY, mainConversationScopeKey(conversationId));
929
1094
  }
@@ -949,6 +1114,7 @@ export class SessionManager {
949
1114
  const path = join(this.mainConversationArchiveDir(parsed.id), "meta.json");
950
1115
  const existing = await readMainConversationArchiveIfValid(path);
951
1116
  await writeJson(path, MainConversationArchiveSchema.parse({
1117
+ ...existing,
952
1118
  ...parsed,
953
1119
  created_at: existing && existing.created_at < parsed.created_at
954
1120
  ? existing.created_at
@@ -1045,6 +1211,39 @@ export class SessionManager {
1045
1211
  }
1046
1212
  return ids;
1047
1213
  }
1214
+ async mainConversationNativeSessions(conversationId, includeActive) {
1215
+ const sessions = new Map();
1216
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
1217
+ if (await pathExists(archiveDir)) {
1218
+ for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
1219
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
1220
+ continue;
1221
+ }
1222
+ const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
1223
+ if (record?.scope === "main"
1224
+ && record.role === "main"
1225
+ && safeMainWorkerId(record.worker_id)
1226
+ && entry.name === `${record.worker_id}.json`) {
1227
+ sessions.set(record.worker_id, record);
1228
+ }
1229
+ }
1230
+ }
1231
+ if (includeActive) {
1232
+ const mainDir = this.mainSessionDir();
1233
+ for (const entry of await readdir(mainDir, { withFileTypes: true })) {
1234
+ if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
1235
+ continue;
1236
+ }
1237
+ const record = await this.readNativeSession({ dir: join(mainDir, entry.name) });
1238
+ if (record?.scope === "main"
1239
+ && record.role === "main"
1240
+ && safeMainWorkerId(record.worker_id)) {
1241
+ sessions.set(record.worker_id, record);
1242
+ }
1243
+ }
1244
+ }
1245
+ return [...sessions.values()].sort((left, right) => left.worker_id.localeCompare(right.worker_id));
1246
+ }
1048
1247
  async appendEvent(task, type, message) {
1049
1248
  const event = {
1050
1249
  time: this.now().toISOString(),
@@ -1069,6 +1268,20 @@ export class SessionManager {
1069
1268
  return run(task, meta);
1070
1269
  });
1071
1270
  }
1271
+ async withMainConversationManagementLease(conversationId, operation, run) {
1272
+ const id = conversationId === null ? null : ConversationIdSchema.parse(conversationId);
1273
+ const main = this.taskFromId("main");
1274
+ await ensureDir(main.dir);
1275
+ const lease = await this.claimTaskRunLease(main.dir);
1276
+ return runWithLeaseFinalization(`Main conversation ${operation}`, lease, async () => {
1277
+ const conversation = (await this.listMainConversations(500, { includeArchived: true }))
1278
+ .find((entry) => entry.id === id);
1279
+ if (!conversation) {
1280
+ throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
1281
+ }
1282
+ return run(main, conversation);
1283
+ });
1284
+ }
1072
1285
  assertTerminalTask(meta, operation) {
1073
1286
  if (!TERMINAL_TASK_STATES.has(meta.status)) {
1074
1287
  throw new Error(`Cannot ${operation} task ${meta.id} while it is ${meta.status}.`);
@@ -1620,6 +1833,10 @@ function nextConversationId(baseId, previousId) {
1620
1833
  function mainConversationScopeKey(conversationId) {
1621
1834
  return conversationId ?? LEGACY_MAIN_CONVERSATION_DIRECTORY;
1622
1835
  }
1836
+ function withoutMainConversationArchiveTime(metadata) {
1837
+ const { archived_at: _archivedAt, ...active } = metadata;
1838
+ return MainConversationArchiveSchema.parse(active);
1839
+ }
1623
1840
  function mainConversationTitle(text, conversationId) {
1624
1841
  const sanitized = sanitizePersistedMainMessage("user", text)
1625
1842
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
@@ -1634,9 +1851,75 @@ function mainConversationTitle(text, conversationId) {
1634
1851
  ? `${characters.slice(0, 77).join("")}...`
1635
1852
  : sanitized;
1636
1853
  }
1854
+ function normalizeMainConversationTitle(title) {
1855
+ const normalized = title
1856
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
1857
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1858
+ .replace(/\s+/g, " ")
1859
+ .trim();
1860
+ if (!normalized) {
1861
+ throw new Error("Main conversation title cannot be empty.");
1862
+ }
1863
+ if (Array.from(normalized).length > 160) {
1864
+ throw new Error("Main conversation title cannot exceed 160 characters.");
1865
+ }
1866
+ return normalized;
1867
+ }
1637
1868
  function safeMainWorkerId(workerId) {
1638
1869
  return SAFE_MAIN_WORKER_ID.test(workerId) && workerId !== "." && workerId !== "..";
1639
1870
  }
1871
+ async function writeConversationChatFile(sourcePath, destinationPath, conversationId, mode) {
1872
+ await ensureDir(dirname(destinationPath));
1873
+ const output = await open(destinationPath, "wx");
1874
+ let matched = 0;
1875
+ let buffer = "";
1876
+ try {
1877
+ if (!(await pathExists(sourcePath))) {
1878
+ await output.close();
1879
+ return 0;
1880
+ }
1881
+ const input = createReadStream(sourcePath, { encoding: "utf8" });
1882
+ const lines = createInterface({ input, crlfDelay: Infinity });
1883
+ for await (const line of lines) {
1884
+ const belongs = chatLineBelongsToMainConversation(line, conversationId);
1885
+ if (belongs) {
1886
+ matched += 1;
1887
+ }
1888
+ if ((mode === "include") !== belongs) {
1889
+ continue;
1890
+ }
1891
+ buffer += `${line}\n`;
1892
+ if (Buffer.byteLength(buffer, "utf8") >= 64 * 1024) {
1893
+ await output.write(buffer);
1894
+ buffer = "";
1895
+ }
1896
+ }
1897
+ if (buffer) {
1898
+ await output.write(buffer);
1899
+ }
1900
+ await output.close();
1901
+ return matched;
1902
+ }
1903
+ catch (error) {
1904
+ await output.close().catch(() => undefined);
1905
+ await removeIfExists(destinationPath).catch(() => undefined);
1906
+ throw error;
1907
+ }
1908
+ }
1909
+ function chatLineBelongsToMainConversation(line, conversationId) {
1910
+ try {
1911
+ const parsed = ChatRecordSchema.safeParse(JSON.parse(line));
1912
+ if (!parsed.success || parsed.data.task_id) {
1913
+ return false;
1914
+ }
1915
+ return conversationId
1916
+ ? parsed.data.conversation_id === conversationId
1917
+ : !parsed.data.conversation_id;
1918
+ }
1919
+ catch {
1920
+ return false;
1921
+ }
1922
+ }
1640
1923
  function normalizeTaskTitle(title) {
1641
1924
  const normalized = title
1642
1925
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
@@ -209,7 +209,9 @@ export const MainConversationArchiveSchema = z.object({
209
209
  version: z.literal(1),
210
210
  id: ConversationIdSchema.nullable(),
211
211
  created_at: z.string().datetime(),
212
- last_activated_at: z.string().datetime()
212
+ last_activated_at: z.string().datetime(),
213
+ title: z.string().min(1).max(160).optional(),
214
+ archived_at: z.string().datetime().optional()
213
215
  });
214
216
  export const ChatRecordSchema = z.object({
215
217
  time: z.string().datetime(),
package/dist/tui/App.js CHANGED
@@ -50,7 +50,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
50
50
  errorOffsets: [],
51
51
  diffOffsets: []
52
52
  };
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
+ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, reloadConfig, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadMainConversations, activateMainConversation, renameMainConversation, setMainConversationArchived, deleteMainConversation, exportMainConversation, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, startMainConversation, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
54
54
  configureTuiTheme({
55
55
  theme: config.ui.theme,
56
56
  colors: config.ui.colors
@@ -101,6 +101,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
101
101
  const [sessionCenterMode, setSessionCenterMode] = useState("tasks");
102
102
  const [mainConversations, setMainConversations] = useState([]);
103
103
  const [selectedMainConversationIndex, setSelectedMainConversationIndex] = useState(0);
104
+ const [mainConversationsIncludeArchived, setMainConversationsIncludeArchived] = useState(false);
105
+ const [mainConversationAction, setMainConversationAction] = useState(null);
104
106
  const [taskSessionsLoading, setTaskSessionsLoading] = useState(false);
105
107
  const [taskSessionsError, setTaskSessionsError] = useState(null);
106
108
  const [taskSessionsNotice, setTaskSessionsNotice] = useState(null);
@@ -156,6 +158,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
156
158
  const sessionCenterModeRef = useRef("tasks");
157
159
  const mainConversationsRef = useRef(mainConversations);
158
160
  const selectedMainConversationIndexRef = useRef(selectedMainConversationIndex);
161
+ const mainConversationsIncludeArchivedRef = useRef(mainConversationsIncludeArchived);
162
+ const mainConversationActionRef = useRef(mainConversationAction);
159
163
  const taskSessionsLoadingRef = useRef(taskSessionsLoading);
160
164
  const taskSessionsIncludeArchivedRef = useRef(taskSessionsIncludeArchived);
161
165
  const taskSessionActionRef = useRef(taskSessionAction);
@@ -188,7 +192,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
188
192
  const activateSelectedTaskSessionRef = useRef(activateSelectedTaskSession);
189
193
  const refreshTaskSessionsRef = useRef(refreshTaskSessions);
190
194
  const openMainConversationsRef = useRef(openMainConversations);
195
+ const refreshMainConversationsRef = useRef(refreshMainConversations);
191
196
  const restoreSelectedMainConversationRef = useRef(restoreSelectedMainConversation);
197
+ const renameSelectedMainConversationRef = useRef(renameSelectedMainConversation);
198
+ const archiveSelectedMainConversationRef = useRef(archiveSelectedMainConversation);
199
+ const deleteSelectedMainConversationRef = useRef(deleteSelectedMainConversation);
200
+ const exportSelectedMainConversationRef = useRef(exportSelectedMainConversation);
192
201
  const renameSelectedTaskSessionRef = useRef(renameSelectedTaskSession);
193
202
  const archiveSelectedTaskSessionRef = useRef(archiveSelectedTaskSession);
194
203
  const deleteSelectedTaskSessionRef = useRef(deleteSelectedTaskSession);
@@ -352,6 +361,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
352
361
  useEffect(() => {
353
362
  selectedMainConversationIndexRef.current = selectedMainConversationIndex;
354
363
  }, [selectedMainConversationIndex]);
364
+ useEffect(() => {
365
+ mainConversationsIncludeArchivedRef.current = mainConversationsIncludeArchived;
366
+ }, [mainConversationsIncludeArchived]);
367
+ useEffect(() => {
368
+ mainConversationActionRef.current = mainConversationAction;
369
+ }, [mainConversationAction]);
355
370
  useEffect(() => {
356
371
  taskSessionsLoadingRef.current = taskSessionsLoading;
357
372
  }, [taskSessionsLoading]);
@@ -424,7 +439,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
424
439
  activateSelectedTaskSessionRef.current = activateSelectedTaskSession;
425
440
  refreshTaskSessionsRef.current = refreshTaskSessions;
426
441
  openMainConversationsRef.current = openMainConversations;
442
+ refreshMainConversationsRef.current = refreshMainConversations;
427
443
  restoreSelectedMainConversationRef.current = restoreSelectedMainConversation;
444
+ renameSelectedMainConversationRef.current = renameSelectedMainConversation;
445
+ archiveSelectedMainConversationRef.current = archiveSelectedMainConversation;
446
+ deleteSelectedMainConversationRef.current = deleteSelectedMainConversation;
447
+ exportSelectedMainConversationRef.current = exportSelectedMainConversation;
428
448
  renameSelectedTaskSessionRef.current = renameSelectedTaskSession;
429
449
  archiveSelectedTaskSessionRef.current = archiveSelectedTaskSession;
430
450
  deleteSelectedTaskSessionRef.current = deleteSelectedTaskSession;
@@ -875,7 +895,41 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
875
895
  return;
876
896
  }
877
897
  if (sessionCenterModeRef.current === "conversations") {
898
+ const conversationAction = mainConversationActionRef.current;
899
+ if (conversationAction) {
900
+ if (chunk === "\x1b") {
901
+ updateMainConversationAction(null);
902
+ setTaskSessionsError(null);
903
+ return;
904
+ }
905
+ if (taskSessionsLoadingRef.current) {
906
+ return;
907
+ }
908
+ if (conversationAction.type === "rename") {
909
+ const update = applyChatInputChunk(conversationAction.value, chunk, conversationAction.cursor);
910
+ if (update.submit !== null) {
911
+ void renameSelectedMainConversationRef.current({
912
+ ...conversationAction,
913
+ value: update.submit,
914
+ cursor: Array.from(update.submit).length
915
+ });
916
+ }
917
+ else {
918
+ updateMainConversationAction({
919
+ ...conversationAction,
920
+ value: update.value,
921
+ cursor: update.cursor
922
+ });
923
+ }
924
+ return;
925
+ }
926
+ if (chunk === "d" || chunk === "D") {
927
+ void deleteSelectedMainConversationRef.current(conversationAction);
928
+ }
929
+ return;
930
+ }
878
931
  if (isTaskSessionsShortcut(chunk, {}) || chunk === "\x1b") {
932
+ updateMainConversationAction(null);
879
933
  setTaskSessionsError(null);
880
934
  setTaskSessionsNotice(null);
881
935
  viewRef.current = taskSessionsReturnViewRef.current;
@@ -890,6 +944,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
890
944
  return;
891
945
  }
892
946
  if (chunk === "t" || chunk === "T") {
947
+ updateMainConversationAction(null);
893
948
  sessionCenterModeRef.current = "tasks";
894
949
  setSessionCenterMode("tasks");
895
950
  setTaskSessionsError(null);
@@ -901,6 +956,63 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
901
956
  void openMainConversationsRef.current();
902
957
  return;
903
958
  }
959
+ const selectedConversation = mainConversationsRef.current[selectedMainConversationIndexRef.current];
960
+ if (chunk === "r" || chunk === "R") {
961
+ if (selectedConversation) {
962
+ updateMainConversationAction({
963
+ type: "rename",
964
+ conversationId: selectedConversation.id,
965
+ title: selectedConversation.title,
966
+ value: selectedConversation.title,
967
+ cursor: Array.from(selectedConversation.title).length
968
+ });
969
+ setTaskSessionsError(null);
970
+ setTaskSessionsNotice(null);
971
+ }
972
+ return;
973
+ }
974
+ if (chunk === "a" || chunk === "A") {
975
+ void archiveSelectedMainConversationRef.current();
976
+ return;
977
+ }
978
+ if (chunk === "d" || chunk === "D") {
979
+ if (selectedConversation) {
980
+ if (selectedConversation.current) {
981
+ setTaskSessionsError("Restore another Main conversation before deleting the current one");
982
+ }
983
+ else {
984
+ updateMainConversationAction({
985
+ type: "delete",
986
+ conversationId: selectedConversation.id,
987
+ title: selectedConversation.title
988
+ });
989
+ setTaskSessionsError(null);
990
+ setTaskSessionsNotice(null);
991
+ }
992
+ }
993
+ return;
994
+ }
995
+ if (chunk === "e" || chunk === "E") {
996
+ void exportSelectedMainConversationRef.current();
997
+ return;
998
+ }
999
+ if (chunk === "h" || chunk === "H") {
1000
+ const includeArchived = !mainConversationsIncludeArchivedRef.current;
1001
+ mainConversationsIncludeArchivedRef.current = includeArchived;
1002
+ setMainConversationsIncludeArchived(includeArchived);
1003
+ setTaskSessionsError(null);
1004
+ setTaskSessionsNotice(null);
1005
+ void (async () => {
1006
+ await refreshMainConversationsRef.current(selectedConversation?.id);
1007
+ if (sessionCenterModeRef.current === "conversations"
1008
+ && mainConversationsIncludeArchivedRef.current === includeArchived) {
1009
+ setTaskSessionsNotice(includeArchived
1010
+ ? "Archived conversations shown"
1011
+ : "Archived conversations hidden");
1012
+ }
1013
+ })();
1014
+ return;
1015
+ }
904
1016
  if (chunk === "\r" || chunk === "\n") {
905
1017
  void restoreSelectedMainConversationRef.current();
906
1018
  return;
@@ -2285,6 +2397,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2285
2397
  setTaskSessionDetailSelectedWorkerIndex(0);
2286
2398
  setTaskSessionDetailNotice(null);
2287
2399
  updateTaskSessionAction(null);
2400
+ updateMainConversationAction(null);
2288
2401
  sessionCenterModeRef.current = "tasks";
2289
2402
  setSessionCenterMode("tasks");
2290
2403
  await refreshTaskSessions(activeTaskIdRef.current);
@@ -2347,24 +2460,32 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2347
2460
  taskSessionDetailsRef.current = null;
2348
2461
  setTaskSessionDetails(null);
2349
2462
  updateTaskSessionAction(null);
2463
+ updateMainConversationAction(null);
2350
2464
  setTaskSessionsError(null);
2351
2465
  setTaskSessionsNotice(null);
2352
- taskSessionsLoadingRef.current = true;
2353
- setTaskSessionsLoading(true);
2466
+ await refreshMainConversations(previousId);
2467
+ }
2468
+ async function refreshMainConversations(preferredConversationId, manageLoading = true) {
2469
+ if (manageLoading) {
2470
+ taskSessionsLoadingRef.current = true;
2471
+ setTaskSessionsLoading(true);
2472
+ }
2354
2473
  const sequence = taskSessionsLoadSequenceRef.current + 1;
2355
2474
  taskSessionsLoadSequenceRef.current = sequence;
2356
2475
  try {
2357
2476
  if (!loadMainConversations) {
2358
2477
  throw new Error("Main conversations are unavailable");
2359
2478
  }
2360
- const conversations = await loadMainConversations();
2479
+ const conversations = await loadMainConversations({
2480
+ includeArchived: mainConversationsIncludeArchivedRef.current
2481
+ });
2361
2482
  if (taskSessionsLoadSequenceRef.current !== sequence) {
2362
2483
  return;
2363
2484
  }
2364
2485
  mainConversationsRef.current = conversations;
2365
2486
  setMainConversations(conversations);
2366
- const preservedIndex = previousId !== undefined
2367
- ? conversations.findIndex((conversation) => conversation.id === previousId)
2487
+ const preservedIndex = preferredConversationId !== undefined
2488
+ ? conversations.findIndex((conversation) => conversation.id === preferredConversationId)
2368
2489
  : -1;
2369
2490
  const currentIndex = conversations.findIndex((conversation) => conversation.current);
2370
2491
  const selectedIndex = preservedIndex >= 0
@@ -2381,7 +2502,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2381
2502
  }
2382
2503
  }
2383
2504
  finally {
2384
- if (taskSessionsLoadSequenceRef.current === sequence) {
2505
+ if (manageLoading && taskSessionsLoadSequenceRef.current === sequence) {
2385
2506
  taskSessionsLoadingRef.current = false;
2386
2507
  setTaskSessionsLoading(false);
2387
2508
  }
@@ -2405,8 +2526,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2405
2526
  }
2406
2527
  const result = await activateMainConversation(selected.id);
2407
2528
  if (!result.changed) {
2408
- await openMainConversations();
2409
2529
  setTaskSessionsNotice(`Already current · ${result.conversation.title}`);
2530
+ await refreshMainConversations(result.conversation.id, false);
2410
2531
  return;
2411
2532
  }
2412
2533
  await activateTaskSession?.(null);
@@ -2424,6 +2545,79 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2424
2545
  setTaskSessionsLoading(false);
2425
2546
  }
2426
2547
  }
2548
+ function updateMainConversationAction(next) {
2549
+ mainConversationActionRef.current = next;
2550
+ setMainConversationAction(next);
2551
+ }
2552
+ async function runMainConversationOperation(run, notice, preferredConversationId) {
2553
+ if (taskSessionsLoadingRef.current) {
2554
+ return;
2555
+ }
2556
+ taskSessionsLoadingRef.current = true;
2557
+ setTaskSessionsLoading(true);
2558
+ setTaskSessionsError(null);
2559
+ setTaskSessionsNotice(null);
2560
+ try {
2561
+ const result = await run();
2562
+ updateMainConversationAction(null);
2563
+ await refreshMainConversations(preferredConversationId, false);
2564
+ taskSessionsLoadingRef.current = false;
2565
+ setTaskSessionsLoading(false);
2566
+ setTaskSessionsNotice(typeof notice === "function" ? notice(result) : notice);
2567
+ }
2568
+ catch (error) {
2569
+ taskSessionsLoadingRef.current = false;
2570
+ setTaskSessionsLoading(false);
2571
+ setTaskSessionsError(error instanceof Error ? error.message : String(error));
2572
+ }
2573
+ finally {
2574
+ taskSessionsLoadingRef.current = false;
2575
+ setTaskSessionsLoading(false);
2576
+ }
2577
+ }
2578
+ async function renameSelectedMainConversation(action) {
2579
+ await runMainConversationOperation(async () => {
2580
+ if (!renameMainConversation) {
2581
+ throw new Error("Main conversation rename is unavailable");
2582
+ }
2583
+ const title = action.value.trim();
2584
+ await renameMainConversation(action.conversationId, title);
2585
+ return title;
2586
+ }, (title) => `Renamed · ${title}`, action.conversationId);
2587
+ }
2588
+ async function archiveSelectedMainConversation() {
2589
+ const selected = mainConversationsRef.current[selectedMainConversationIndexRef.current];
2590
+ if (!selected) {
2591
+ return;
2592
+ }
2593
+ const archived = !selected.archivedAt;
2594
+ await runMainConversationOperation(async () => {
2595
+ if (!setMainConversationArchived) {
2596
+ throw new Error("Main conversation archive is unavailable");
2597
+ }
2598
+ await setMainConversationArchived(selected.id, archived);
2599
+ }, archived ? `Archived · ${selected.title}` : `Unarchived · ${selected.title}`, selected.id);
2600
+ }
2601
+ async function deleteSelectedMainConversation(action) {
2602
+ await runMainConversationOperation(async () => {
2603
+ if (!deleteMainConversation) {
2604
+ throw new Error("Main conversation deletion is unavailable");
2605
+ }
2606
+ await deleteMainConversation(action.conversationId);
2607
+ }, `Deleted · ${action.title}`);
2608
+ }
2609
+ async function exportSelectedMainConversation() {
2610
+ const selected = mainConversationsRef.current[selectedMainConversationIndexRef.current];
2611
+ if (!selected) {
2612
+ return;
2613
+ }
2614
+ await runMainConversationOperation(async () => {
2615
+ if (!exportMainConversation) {
2616
+ throw new Error("Main conversation export is unavailable");
2617
+ }
2618
+ return exportMainConversation(selected.id);
2619
+ }, (path) => `Exported · ${path}`, selected.id);
2620
+ }
2427
2621
  function updateTaskSessionAction(next) {
2428
2622
  taskSessionActionRef.current = next;
2429
2623
  setTaskSessionAction(next);
@@ -2907,15 +3101,27 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2907
3101
  if (view === "workspace") {
2908
3102
  return (_jsx(Box, { flexDirection: "column", height: terminalSize.rows, children: _jsx(WorkspacePicker, { cwd: cwd, choices: workspaceChoices, terminalHeight: terminalSize.rows, terminalWidth: terminalWidth, onCancel: closeWorkspacePicker, onSelect: (workspace) => void selectWorkspace(workspace) }) }));
2909
3103
  }
2910
- return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: taskSessionAction?.type === "rename"
2911
- ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
2912
- : taskSessionAction?.type === "delete"
2913
- ? { type: "delete", title: taskSessionAction.title }
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"
3104
+ return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: sessionCenterMode === "conversations"
3105
+ ? mainConversationAction?.type === "rename"
3106
+ ? { type: "rename", value: mainConversationAction.value, cursor: mainConversationAction.cursor }
3107
+ : mainConversationAction?.type === "delete"
3108
+ ? { type: "delete", title: mainConversationAction.title }
3109
+ : null
3110
+ : taskSessionAction?.type === "rename"
3111
+ ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
3112
+ : taskSessionAction?.type === "delete"
3113
+ ? { type: "delete", title: taskSessionAction.title }
3114
+ : null, taskSessionsIncludeArchived: sessionCenterMode === "conversations"
3115
+ ? mainConversationsIncludeArchived
3116
+ : 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"
2915
3117
  ? workerSearch.query
2916
3118
  : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
2917
3119
  ? workerSearch.cursor
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"
3120
+ : 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, includeArchived: mainConversationsIncludeArchived, notice: taskSessionsNotice, action: mainConversationAction?.type === "rename"
3121
+ ? { type: "rename", title: mainConversationAction.title }
3122
+ : mainConversationAction?.type === "delete"
3123
+ ? { type: "delete", title: mainConversationAction.title }
3124
+ : null, loading: taskSessionsLoading, error: taskSessionsError, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2919
3125
  ? { type: "rename", title: taskSessionAction.title }
2920
3126
  : taskSessionAction?.type === "delete"
2921
3127
  ? { type: "delete", title: taskSessionAction.title }
@@ -40,7 +40,7 @@ export function InputBar({ mode, ready = true, busy = false, routeFallback = fal
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
42
  if (mainConversationSessions) {
43
- const hints = mainConversationSessionsInputHints(terminalWidth);
43
+ const hints = mainConversationSessionsInputHints(terminalWidth, taskSessionsIncludeArchived);
44
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
45
  }
46
46
  const hints = taskSessionsInputHints(terminalWidth, taskSessionsIncludeArchived);
@@ -588,8 +588,14 @@ function taskSessionsInputHints(width, includeArchived) {
588
588
  { label: "s", detail: "" }
589
589
  ]);
590
590
  }
591
- function mainConversationSessionsInputHints(width) {
591
+ export function mainConversationSessionsInputHints(width, includeArchived) {
592
+ const archivedAction = includeArchived ? "H hide archived" : "H archived";
592
593
  return selectInputHints(width, [
594
+ { label: "conversations", detail: ` · Up/Dn select · Enter restore · R rename · A archive · D delete · E export · ${archivedAction} · N new · T tasks · Esc back` },
595
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · R rename · A archive · D delete · E export · N new · T tasks · Esc back" },
596
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · R rename · A archive · D delete · N new · T tasks · Esc back" },
597
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · R rename · A archive · N new · T tasks · Esc back" },
598
+ { label: "conversations", detail: " · Up/Dn select · Enter restore · R rename · N new · T tasks · Esc back" },
593
599
  { label: "conversations", detail: " · Up/Dn select · Enter restore · N new · T tasks · Esc back" },
594
600
  { label: "conversations", detail: " · Up/Dn select · Enter restore · T tasks · Esc back" },
595
601
  { label: "conversations", detail: " · Up/Dn select · Enter restore · Esc back" },
@@ -2,10 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { compactEndByDisplayWidth, displayWidth } from "./display-width.js";
4
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 }) {
5
+ export function MainConversationsView({ conversations, selectedIndex, includeArchived = false, notice = null, action = null, loading = false, error = null, height = 20, terminalWidth = process.stdout.columns || 120 }) {
6
6
  const viewportHeight = Math.max(1, height);
7
7
  const width = mainConversationsContentWidth(terminalWidth);
8
- const lines = mainConversationsDisplayLines(conversations, selectedIndex, viewportHeight, terminalWidth, { notice, loading, error });
8
+ const lines = mainConversationsDisplayLines(conversations, selectedIndex, viewportHeight, terminalWidth, { includeArchived, notice, action, loading, error });
9
9
  const blankRows = Math.max(0, viewportHeight - lines.length);
10
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
11
  }
@@ -13,15 +13,26 @@ export function mainConversationsDisplayLines(conversations, selectedIndex, heig
13
13
  const viewportHeight = Math.max(1, Math.trunc(height));
14
14
  const width = mainConversationsContentWidth(terminalWidth);
15
15
  const lines = [{
16
- text: fitMainConversationCandidates(["Main conversations", "Conversations", "Chats", "C"], width),
16
+ text: fitMainConversationCandidates([
17
+ state.includeArchived ? "Main conversations · archived shown" : "Main conversations",
18
+ state.includeArchived ? "Conversations · all" : "Conversations",
19
+ "Chats",
20
+ "C"
21
+ ], width),
17
22
  tone: "heading"
18
23
  }];
19
24
  if (viewportHeight >= 3) {
20
25
  const messages = conversations.reduce((sum, conversation) => sum + conversation.messageCount, 0);
21
26
  const nativeSessions = conversations.reduce((sum, conversation) => sum + conversation.nativeSessionCount, 0);
27
+ const archived = conversations.filter((conversation) => conversation.archivedAt).length;
22
28
  lines.push({
23
29
  text: fitMainConversationCandidates([
24
- `${conversations.length} ${conversations.length === 1 ? "conversation" : "conversations"} · ${messages} messages · ${nativeSessions} native`,
30
+ [
31
+ `${conversations.length} ${conversations.length === 1 ? "conversation" : "conversations"}`,
32
+ `${messages} messages`,
33
+ `${nativeSessions} native`,
34
+ ...(archived > 0 ? [`${archived} archived`] : [])
35
+ ].join(" · "),
25
36
  `${conversations.length} conversations · ${messages} messages`,
26
37
  `${conversations.length} conversations`,
27
38
  `${conversations.length} chats`
@@ -29,7 +40,15 @@ export function mainConversationsDisplayLines(conversations, selectedIndex, heig
29
40
  tone: "muted"
30
41
  });
31
42
  }
32
- if (viewportHeight >= 4 && state.notice) {
43
+ if (viewportHeight >= 4 && state.action) {
44
+ lines.push({
45
+ text: fitMainConversationText(state.action.type === "rename"
46
+ ? `rename · ${safeMainConversationText(state.action.title)} · Enter save · Esc cancel`
47
+ : `delete · ${safeMainConversationText(state.action.title)} · press D again · Esc cancel`, width),
48
+ tone: state.action.type === "delete" ? "danger" : "active"
49
+ });
50
+ }
51
+ else if (viewportHeight >= 4 && state.notice) {
33
52
  lines.push({ text: fitMainConversationText(state.notice, width), tone: "success" });
34
53
  }
35
54
  const slots = Math.max(0, viewportHeight - lines.length);
@@ -88,14 +107,15 @@ function mainConversationRowText(conversation, selected, width) {
88
107
  const title = safeMainConversationText(conversation.title);
89
108
  const messages = `${conversation.messageCount} ${conversation.messageCount === 1 ? "message" : "messages"}`;
90
109
  const native = `${conversation.nativeSessionCount} native`;
110
+ const status = conversation.archivedAt ? "archived" : null;
91
111
  const date = conversation.lastActivityAt.slice(5, 16).replace("T", " ");
92
112
  const scope = conversation.id
93
113
  ? `#${conversation.id.replace(/^conversation-/, "")}`
94
114
  : "legacy";
95
115
  return fitMainConversationCandidates([
96
- [marker + title, messages, native, date].join(" · "),
97
- [marker + title, messages, native].join(" · "),
98
- [marker + title, messages].join(" · "),
116
+ [marker + title, status, messages, native, date].filter(Boolean).join(" · "),
117
+ [marker + title, status, messages, native].filter(Boolean).join(" · "),
118
+ [marker + title, status, messages].filter(Boolean).join(" · "),
99
119
  [marker + title, date].join(" · "),
100
120
  [marker + scope, messages].join(" · "),
101
121
  marker.trimEnd()
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.2.9";
1
+ export const version = "0.2.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",