parallel-codex-tui 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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(),