parallel-codex-tui 0.2.8 → 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.
@@ -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";
@@ -11,7 +13,7 @@ import { loadCollaborationTimeline } from "./collaboration-timeline.js";
11
13
  import { taskStateTransitionAllowed } from "./task-state-machine.js";
12
14
  import { processIsAlive, readProcessStartToken } from "./process-identity.js";
13
15
  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";
16
+ import { ChatRecordSchema, ConversationIdSchema, MainConversationArchiveSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
15
17
  export class InterruptedTaskRecoveryBlockedError extends Error {
16
18
  taskId;
17
19
  blocks;
@@ -42,6 +44,9 @@ const ACTIVE_FEATURE_STATES = new Set([
42
44
  ]);
43
45
  const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
44
46
  const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
47
+ const MAIN_CONVERSATION_ARCHIVE_DIRECTORY = "conversations";
48
+ const LEGACY_MAIN_CONVERSATION_DIRECTORY = "legacy";
49
+ const SAFE_MAIN_WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
45
50
  const CompletionContractSchema = z.object({
46
51
  version: z.literal(1),
47
52
  final_judge_required: z.literal(true)
@@ -180,24 +185,99 @@ export class SessionManager {
180
185
  }
181
186
  return readJson(path, MainConversationStateSchema);
182
187
  }
188
+ async listMainConversations(limit = 100, options = {}) {
189
+ const boundedLimit = Number.isFinite(limit)
190
+ ? Math.min(500, Math.max(0, Math.trunc(limit)))
191
+ : 100;
192
+ if (boundedLimit === 0) {
193
+ return [];
194
+ }
195
+ const [current, records, archives] = await Promise.all([
196
+ this.readMainConversationState(),
197
+ readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, 10000, { filter: (record) => !record.task_id }),
198
+ this.readMainConversationArchives()
199
+ ]);
200
+ const currentId = current?.id ?? null;
201
+ const conversations = new Map();
202
+ const ensureConversation = (id, createdAt, lastActivityAt = createdAt) => {
203
+ const key = mainConversationScopeKey(id);
204
+ const existing = conversations.get(key);
205
+ if (existing) {
206
+ if (createdAt < existing.createdAt) {
207
+ existing.createdAt = createdAt;
208
+ }
209
+ if (lastActivityAt > existing.lastActivityAt) {
210
+ existing.lastActivityAt = lastActivityAt;
211
+ }
212
+ return existing;
213
+ }
214
+ const summary = {
215
+ id,
216
+ title: id ? "Untitled conversation" : "Legacy conversation",
217
+ createdAt,
218
+ lastActivityAt,
219
+ messageCount: 0,
220
+ userMessageCount: 0,
221
+ nativeSessionCount: 0,
222
+ current: id === currentId
223
+ };
224
+ conversations.set(key, summary);
225
+ return summary;
226
+ };
227
+ for (const archive of archives) {
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
+ }
235
+ }
236
+ for (const record of records) {
237
+ const id = record.conversation_id ?? null;
238
+ const conversation = ensureConversation(id, record.time, record.time);
239
+ conversation.messageCount += 1;
240
+ if (record.from === "user") {
241
+ conversation.userMessageCount += 1;
242
+ if (conversation.title === "Untitled conversation" || conversation.title === "Legacy conversation") {
243
+ conversation.title = mainConversationTitle(record.text, id);
244
+ }
245
+ }
246
+ }
247
+ if (current) {
248
+ ensureConversation(current.id, current.created_at, current.created_at).current = true;
249
+ }
250
+ else if (!conversations.has(LEGACY_MAIN_CONVERSATION_DIRECTORY)) {
251
+ const createdAt = this.now().toISOString();
252
+ ensureConversation(null, createdAt, createdAt).current = true;
253
+ }
254
+ const activeNativeSessions = await this.activeMainNativeSessionIds();
255
+ await Promise.all([...conversations.values()].map(async (conversation) => {
256
+ const archived = await this.archivedMainNativeSessionIds(conversation.id);
257
+ if (conversation.current) {
258
+ for (const sessionId of activeNativeSessions) {
259
+ archived.add(sessionId);
260
+ }
261
+ }
262
+ conversation.nativeSessionCount = archived.size;
263
+ }));
264
+ return [...conversations.values()]
265
+ .filter((conversation) => options.includeArchived || !conversation.archivedAt || conversation.current)
266
+ .sort((left, right) => (Number(right.current) - Number(left.current)
267
+ || Number(Boolean(left.archivedAt)) - Number(Boolean(right.archivedAt))
268
+ || right.lastActivityAt.localeCompare(left.lastActivityAt)
269
+ || mainConversationScopeKey(right.id).localeCompare(mainConversationScopeKey(left.id))))
270
+ .slice(0, boundedLimit);
271
+ }
183
272
  async startNewMainConversation() {
184
273
  const main = this.taskFromId("main");
185
274
  await ensureDir(main.dir);
186
275
  const lease = await this.claimTaskRunLease(main.dir);
187
276
  return runWithLeaseFinalization("Main conversation reset", lease, async () => {
188
277
  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
278
  const createdAt = this.now();
279
+ const currentSummary = (await this.listMainConversations(500)).find((conversation) => conversation.current);
280
+ 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
281
  const baseId = `conversation-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
202
282
  const state = MainConversationStateSchema.parse({
203
283
  version: 1,
@@ -206,10 +286,198 @@ export class SessionManager {
206
286
  ...(previous ? { previous_id: previous.id } : {})
207
287
  });
208
288
  await writeJson(join(main.dir, "conversation.json"), state);
289
+ await this.writeMainConversationArchive({
290
+ version: 1,
291
+ id: state.id,
292
+ created_at: state.created_at,
293
+ last_activated_at: state.created_at
294
+ });
209
295
  await this.appendEvent(main, "main.conversation_started", `Started ${state.id}`);
210
296
  return state;
211
297
  });
212
298
  }
299
+ async activateMainConversation(conversationId) {
300
+ const id = conversationId === null ? null : ConversationIdSchema.parse(conversationId);
301
+ const main = this.taskFromId("main");
302
+ await ensureDir(main.dir);
303
+ const lease = await this.claimTaskRunLease(main.dir);
304
+ return runWithLeaseFinalization("Main conversation restore", lease, async () => {
305
+ const conversations = await this.listMainConversations(500, { includeArchived: true });
306
+ const target = conversations.find((conversation) => conversation.id === id);
307
+ if (!target) {
308
+ throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
309
+ }
310
+ if (target.archivedAt) {
311
+ throw new Error(`Cannot restore archived Main conversation ${id ?? "legacy"}. Unarchive it first.`);
312
+ }
313
+ const current = await this.readMainConversationState();
314
+ const currentId = current?.id ?? null;
315
+ if (currentId === id) {
316
+ return { conversation: target, restoredNativeSessions: 0, changed: false };
317
+ }
318
+ const currentSummary = conversations.find((conversation) => conversation.current);
319
+ const activatedAt = this.now().toISOString();
320
+ await this.archiveAndRetireMainConversation(main, currentId, currentSummary?.createdAt ?? current?.created_at ?? activatedAt, "switch Main conversation", Boolean(current) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
321
+ if (id) {
322
+ await writeJson(join(main.dir, "conversation.json"), MainConversationStateSchema.parse({
323
+ version: 1,
324
+ id,
325
+ created_at: target.createdAt,
326
+ ...(current?.id ? { previous_id: current.id } : {})
327
+ }));
328
+ }
329
+ else {
330
+ await removeIfExists(join(main.dir, "conversation.json"));
331
+ }
332
+ await this.writeMainConversationArchive({
333
+ version: 1,
334
+ id,
335
+ created_at: target.createdAt,
336
+ last_activated_at: activatedAt
337
+ });
338
+ const restoredNativeSessions = await this.restoreMainConversationNativeSessions(main, id);
339
+ await this.appendEvent(main, "main.conversation_restored", `Restored ${id ?? "legacy conversation"}; ${restoredNativeSessions} native session(s)`);
340
+ const restored = (await this.listMainConversations(500)).find((conversation) => conversation.id === id);
341
+ if (!restored) {
342
+ throw new Error(`Restored Main conversation disappeared from its catalog: ${id ?? "legacy"}`);
343
+ }
344
+ return { conversation: restored, restoredNativeSessions, changed: true };
345
+ });
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
+ }
213
481
  async reconcileInterruptedMainSession() {
214
482
  const main = this.taskFromId("main");
215
483
  if (!(await pathExists(main.dir))) {
@@ -803,6 +1071,179 @@ export class SessionManager {
803
1071
  await this.clearWorkerStatusNativeSession(worker);
804
1072
  await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
805
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
+ }
1092
+ mainConversationArchiveDir(conversationId) {
1093
+ return join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY, mainConversationScopeKey(conversationId));
1094
+ }
1095
+ async readMainConversationArchives() {
1096
+ const root = join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY);
1097
+ if (!(await pathExists(root))) {
1098
+ return [];
1099
+ }
1100
+ const archives = [];
1101
+ for (const entry of await readdir(root, { withFileTypes: true })) {
1102
+ if (!entry.isDirectory()) {
1103
+ continue;
1104
+ }
1105
+ const metadata = await readMainConversationArchiveIfValid(join(root, entry.name, "meta.json"));
1106
+ if (metadata && mainConversationScopeKey(metadata.id) === entry.name) {
1107
+ archives.push(metadata);
1108
+ }
1109
+ }
1110
+ return archives;
1111
+ }
1112
+ async writeMainConversationArchive(metadata) {
1113
+ const parsed = MainConversationArchiveSchema.parse(metadata);
1114
+ const path = join(this.mainConversationArchiveDir(parsed.id), "meta.json");
1115
+ const existing = await readMainConversationArchiveIfValid(path);
1116
+ await writeJson(path, MainConversationArchiveSchema.parse({
1117
+ ...existing,
1118
+ ...parsed,
1119
+ created_at: existing && existing.created_at < parsed.created_at
1120
+ ? existing.created_at
1121
+ : parsed.created_at
1122
+ }));
1123
+ }
1124
+ async archiveAndRetireMainConversation(main, conversationId, createdAt, reason, persistEmpty = true) {
1125
+ const activatedAt = this.now().toISOString();
1126
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
1127
+ const entries = await readdir(main.dir, { withFileTypes: true });
1128
+ let archivedNativeSession = false;
1129
+ for (const entry of entries) {
1130
+ if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
1131
+ continue;
1132
+ }
1133
+ const worker = { dir: join(main.dir, entry.name) };
1134
+ const nativeSession = await this.readNativeSession(worker);
1135
+ if (nativeSession?.scope !== "main"
1136
+ || nativeSession.role !== "main"
1137
+ || !safeMainWorkerId(nativeSession.worker_id)) {
1138
+ continue;
1139
+ }
1140
+ await writeJson(join(archiveDir, `${nativeSession.worker_id}.json`), nativeSession);
1141
+ await this.retireNativeSession(worker, reason);
1142
+ archivedNativeSession = true;
1143
+ }
1144
+ if (persistEmpty || archivedNativeSession) {
1145
+ await this.writeMainConversationArchive({
1146
+ version: 1,
1147
+ id: conversationId,
1148
+ created_at: createdAt,
1149
+ last_activated_at: activatedAt
1150
+ });
1151
+ }
1152
+ }
1153
+ async restoreMainConversationNativeSessions(main, conversationId) {
1154
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
1155
+ if (!(await pathExists(archiveDir))) {
1156
+ return 0;
1157
+ }
1158
+ let restored = 0;
1159
+ for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
1160
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
1161
+ continue;
1162
+ }
1163
+ const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
1164
+ if (!record
1165
+ || record.scope !== "main"
1166
+ || record.role !== "main"
1167
+ || !safeMainWorkerId(record.worker_id)
1168
+ || entry.name !== `${record.worker_id}.json`) {
1169
+ continue;
1170
+ }
1171
+ const worker = { dir: join(main.dir, record.worker_id) };
1172
+ await ensureDir(worker.dir);
1173
+ await writeJson(join(worker.dir, "native-session.json"), record);
1174
+ await removeIfExists(join(worker.dir, "native-session.retired.json"));
1175
+ await this.syncNativeSessionProjection(worker, record);
1176
+ restored += 1;
1177
+ }
1178
+ return restored;
1179
+ }
1180
+ async archivedMainNativeSessionIds(conversationId) {
1181
+ const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
1182
+ const ids = new Set();
1183
+ if (!(await pathExists(archiveDir))) {
1184
+ return ids;
1185
+ }
1186
+ for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
1187
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
1188
+ continue;
1189
+ }
1190
+ const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
1191
+ if (record?.scope === "main" && record.role === "main") {
1192
+ ids.add(`${record.engine}\u0000${record.session_id}`);
1193
+ }
1194
+ }
1195
+ return ids;
1196
+ }
1197
+ async activeMainNativeSessionIds() {
1198
+ const ids = new Set();
1199
+ const mainDir = this.mainSessionDir();
1200
+ if (!(await pathExists(mainDir))) {
1201
+ return ids;
1202
+ }
1203
+ for (const entry of await readdir(mainDir, { withFileTypes: true })) {
1204
+ if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
1205
+ continue;
1206
+ }
1207
+ const record = await this.readNativeSession({ dir: join(mainDir, entry.name) });
1208
+ if (record?.scope === "main" && record.role === "main") {
1209
+ ids.add(`${record.engine}\u0000${record.session_id}`);
1210
+ }
1211
+ }
1212
+ return ids;
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
+ }
806
1247
  async appendEvent(task, type, message) {
807
1248
  const event = {
808
1249
  time: this.now().toISOString(),
@@ -827,6 +1268,20 @@ export class SessionManager {
827
1268
  return run(task, meta);
828
1269
  });
829
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
+ }
830
1285
  assertTerminalTask(meta, operation) {
831
1286
  if (!TERMINAL_TASK_STATES.has(meta.status)) {
832
1287
  throw new Error(`Cannot ${operation} task ${meta.id} while it is ${meta.status}.`);
@@ -1375,6 +1830,96 @@ function nextConversationId(baseId, previousId) {
1375
1830
  ? `${baseId}-${suffix + 1}`
1376
1831
  : `${baseId}-2`;
1377
1832
  }
1833
+ function mainConversationScopeKey(conversationId) {
1834
+ return conversationId ?? LEGACY_MAIN_CONVERSATION_DIRECTORY;
1835
+ }
1836
+ function withoutMainConversationArchiveTime(metadata) {
1837
+ const { archived_at: _archivedAt, ...active } = metadata;
1838
+ return MainConversationArchiveSchema.parse(active);
1839
+ }
1840
+ function mainConversationTitle(text, conversationId) {
1841
+ const sanitized = sanitizePersistedMainMessage("user", text)
1842
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
1843
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1844
+ .replace(/\s+/g, " ")
1845
+ .trim();
1846
+ if (!sanitized) {
1847
+ return conversationId ? "Untitled conversation" : "Legacy conversation";
1848
+ }
1849
+ const characters = Array.from(sanitized);
1850
+ return characters.length > 80
1851
+ ? `${characters.slice(0, 77).join("")}...`
1852
+ : sanitized;
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
+ }
1868
+ function safeMainWorkerId(workerId) {
1869
+ return SAFE_MAIN_WORKER_ID.test(workerId) && workerId !== "." && workerId !== "..";
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
+ }
1378
1923
  function normalizeTaskTitle(title) {
1379
1924
  const normalized = title
1380
1925
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
@@ -1486,6 +2031,17 @@ async function readRetiredNativeSessionIfValid(retiredSessionPath) {
1486
2031
  return null;
1487
2032
  }
1488
2033
  }
2034
+ async function readMainConversationArchiveIfValid(path) {
2035
+ if (!(await pathExists(path))) {
2036
+ return null;
2037
+ }
2038
+ try {
2039
+ return await readJson(path, MainConversationArchiveSchema);
2040
+ }
2041
+ catch {
2042
+ return null;
2043
+ }
2044
+ }
1489
2045
  async function readWorkerStatusIfValid(statusPath) {
1490
2046
  if (!(await pathExists(statusPath))) {
1491
2047
  return null;
@@ -205,6 +205,14 @@ 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
+ title: z.string().min(1).max(160).optional(),
214
+ archived_at: z.string().datetime().optional()
215
+ });
208
216
  export const ChatRecordSchema = z.object({
209
217
  time: z.string().datetime(),
210
218
  from: z.enum(["user", "system"]),