kanna-code 0.32.3 → 0.33.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.
@@ -120,6 +120,38 @@ describe("CodexAppServerManager", () => {
120
120
  ])
121
121
  })
122
122
 
123
+ test("forks a thread when a pending fork session token is provided", async () => {
124
+ const process = new FakeCodexProcess((message, child) => {
125
+ if (message.method === "initialize") {
126
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
127
+ } else if (message.method === "thread/fork") {
128
+ child.writeServerMessage({
129
+ id: message.id,
130
+ result: { thread: { id: "thread-fork-1" }, model: "gpt-5.4", reasoningEffort: "high" },
131
+ })
132
+ }
133
+ })
134
+
135
+ const manager = new CodexAppServerManager({
136
+ spawnProcess: () => process as never,
137
+ })
138
+
139
+ const sessionToken = await manager.startSession({
140
+ chatId: "chat-1",
141
+ cwd: "/tmp/project",
142
+ model: "gpt-5.4",
143
+ sessionToken: null,
144
+ pendingForkSessionToken: "thread-source",
145
+ })
146
+
147
+ expect(sessionToken).toBe("thread-fork-1")
148
+ expect(process.messages.map((message: any) => message.method)).toEqual([
149
+ "initialize",
150
+ "initialized",
151
+ "thread/fork",
152
+ ])
153
+ })
154
+
123
155
  test("maps fast mode and reasoning into app-server params", async () => {
124
156
  const process = new FakeCodexProcess((message, child) => {
125
157
  if (message.method === "initialize") {
@@ -34,6 +34,8 @@ import {
34
34
  type ThreadItem,
35
35
  type ThreadResumeParams,
36
36
  type ThreadResumeResponse,
37
+ type ThreadForkParams,
38
+ type ThreadForkResponse,
37
39
  type ThreadStartParams,
38
40
  type ThreadStartResponse,
39
41
  type ThreadTokenUsageUpdatedNotification,
@@ -118,6 +120,7 @@ export interface StartCodexSessionArgs {
118
120
  model: string
119
121
  serviceTier?: ServiceTier
120
122
  sessionToken: string | null
123
+ pendingForkSessionToken?: string | null
121
124
  }
122
125
 
123
126
  export interface StartCodexTurnArgs {
@@ -745,7 +748,7 @@ export class CodexAppServerManager {
745
748
 
746
749
  async startSession(args: StartCodexSessionArgs) {
747
750
  const existing = this.sessions.get(args.chatId)
748
- if (existing && !existing.closed && existing.cwd === args.cwd) {
751
+ if (existing && !existing.closed && existing.cwd === args.cwd && !args.pendingForkSessionToken) {
749
752
  return
750
753
  }
751
754
 
@@ -791,8 +794,18 @@ export class CodexAppServerManager {
791
794
  persistExtendedHistory: false,
792
795
  } satisfies ThreadStartParams
793
796
 
794
- let response: ThreadStartResponse | ThreadResumeResponse
795
- if (args.sessionToken) {
797
+ let response: ThreadStartResponse | ThreadResumeResponse | ThreadForkResponse
798
+ if (args.pendingForkSessionToken) {
799
+ response = await this.sendRequest<ThreadForkResponse>(context, "thread/fork", {
800
+ threadId: args.pendingForkSessionToken,
801
+ model: args.model,
802
+ cwd: args.cwd,
803
+ serviceTier: args.serviceTier,
804
+ approvalPolicy: "never",
805
+ sandbox: "danger-full-access",
806
+ persistExtendedHistory: false,
807
+ } satisfies ThreadForkParams)
808
+ } else if (args.sessionToken) {
796
809
  try {
797
810
  response = await this.sendRequest<ThreadResumeResponse>(context, "thread/resume", {
798
811
  threadId: args.sessionToken,
@@ -815,6 +828,7 @@ export class CodexAppServerManager {
815
828
  }
816
829
 
817
830
  context.sessionToken = response.thread.id
831
+ return context.sessionToken
818
832
  }
819
833
 
820
834
  async startTurn(args: StartCodexTurnArgs): Promise<HarnessTurn> {
@@ -121,15 +121,6 @@ type SelectedBranch =
121
121
  remoteRef?: string
122
122
  }
123
123
 
124
- async function fileExists(filePath: string) {
125
- try {
126
- await stat(filePath)
127
- return true
128
- } catch {
129
- return false
130
- }
131
- }
132
-
133
124
  async function runGit(args: string[], cwd: string) {
134
125
  const process = Bun.spawn(["git", "-C", cwd, ...args], {
135
126
  stdout: "pipe",
@@ -354,13 +354,85 @@ describe("EventStore", () => {
354
354
  const second = await store.openProject("/tmp/project-b")
355
355
 
356
356
  await store.setSidebarProjectOrder([second.id, first.id])
357
- expect(store.state.sidebarProjectOrder).toEqual([second.id, first.id])
357
+ expect(store.getSidebarProjectOrder()).toEqual([second.id, first.id])
358
+ expect(JSON.parse(await readFile(join(dataDir, "sidebar-order.json"), "utf8"))).toEqual([second.id, first.id])
358
359
 
359
360
  await store.compact()
360
361
 
362
+ const snapshot = JSON.parse(await readFile(join(dataDir, "snapshot.json"), "utf8")) as SnapshotFile
363
+ expect(snapshot.sidebarProjectOrder).toBeUndefined()
364
+
361
365
  const reloaded = new EventStore(dataDir)
362
366
  await reloaded.initialize()
363
- expect(reloaded.state.sidebarProjectOrder).toEqual([second.id, first.id])
367
+ expect(reloaded.getSidebarProjectOrder()).toEqual([second.id, first.id])
368
+ })
369
+
370
+ test("migrates legacy sidebar project order from existing snapshots and project logs", async () => {
371
+ const dataDir = await createTempDataDir()
372
+ const snapshotPath = join(dataDir, "snapshot.json")
373
+ const projectsLogPath = join(dataDir, "projects.jsonl")
374
+
375
+ const snapshot: SnapshotFile = {
376
+ v: 2,
377
+ generatedAt: 10,
378
+ projects: [
379
+ {
380
+ id: "project-1",
381
+ localPath: "/tmp/project-a",
382
+ title: "Project A",
383
+ createdAt: 1,
384
+ updatedAt: 1,
385
+ },
386
+ {
387
+ id: "project-2",
388
+ localPath: "/tmp/project-b",
389
+ title: "Project B",
390
+ createdAt: 2,
391
+ updatedAt: 2,
392
+ },
393
+ ],
394
+ chats: [],
395
+ sidebarProjectOrder: ["project-1"],
396
+ }
397
+
398
+ await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8")
399
+ await writeFile(projectsLogPath, [
400
+ JSON.stringify({
401
+ v: 2,
402
+ type: "sidebar_project_order_set",
403
+ timestamp: 20,
404
+ projectIds: ["project-2", "project-1"],
405
+ }),
406
+ "",
407
+ ].join("\n"), "utf8")
408
+
409
+ const store = new EventStore(dataDir)
410
+ await store.initialize()
411
+
412
+ expect(store.getSidebarProjectOrder()).toEqual(["project-2", "project-1"])
413
+ expect(JSON.parse(await readFile(join(dataDir, "sidebar-order.json"), "utf8"))).toEqual(["project-2", "project-1"])
414
+ })
415
+
416
+ test("ignores an invalid sidebar order file without resetting store state", async () => {
417
+ const dataDir = await createTempDataDir()
418
+ await writeFile(join(dataDir, "sidebar-order.json"), "{not-json", "utf8")
419
+
420
+ const originalWarn = console.warn
421
+ console.warn = () => {}
422
+ try {
423
+ const store = new EventStore(dataDir)
424
+ await store.initialize()
425
+
426
+ const project = await store.openProject("/tmp/project")
427
+
428
+ const reloaded = new EventStore(dataDir)
429
+ await reloaded.initialize()
430
+
431
+ expect(reloaded.getProject(project.id)?.localPath).toBe("/tmp/project")
432
+ expect(reloaded.getSidebarProjectOrder()).toEqual([])
433
+ } finally {
434
+ console.warn = originalWarn
435
+ }
364
436
  })
365
437
 
366
438
  test("prunes stale empty chats after thirty minutes", async () => {
@@ -439,4 +511,30 @@ describe("EventStore", () => {
439
511
  expect(pruned).toEqual([])
440
512
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
441
513
  })
514
+
515
+ test("forks a chat with copied transcript and pending fork session token", async () => {
516
+ const dataDir = await createTempDataDir()
517
+ const store = new EventStore(dataDir)
518
+ await store.initialize()
519
+
520
+ const project = await store.openProject("/tmp/project")
521
+ const source = await store.createChat(project.id)
522
+ await store.setChatProvider(source.id, "claude")
523
+ await store.setPlanMode(source.id, true)
524
+ await store.setSessionToken(source.id, "session-1")
525
+ await store.appendMessage(source.id, entry("user_prompt", source.createdAt + 1, { content: "analyze this" }))
526
+ await store.appendMessage(source.id, entry("assistant_text", source.createdAt + 2, { text: "done" }))
527
+
528
+ const forked = await store.forkChat(source.id)
529
+
530
+ expect(forked.id).not.toBe(source.id)
531
+ expect(forked.title).toBe("Fork: New Chat")
532
+ expect(forked.provider).toBe("claude")
533
+ expect(forked.planMode).toBe(true)
534
+ expect(forked.sessionToken).toBeNull()
535
+ expect(forked.pendingForkSessionToken).toBe("session-1")
536
+ expect(forked.lastTurnOutcome).toBeNull()
537
+ expect(forked.lastMessageAt).toBeUndefined()
538
+ expect(store.getMessages(forked.id)).toEqual(store.getMessages(source.id))
539
+ })
442
540
  })
@@ -20,6 +20,25 @@ import { resolveLocalPath } from "./paths"
20
20
 
21
21
  const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
22
22
  const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
23
+ const SIDEBAR_PROJECT_ORDER_FILE = "sidebar-order.json"
24
+
25
+ function normalizeSidebarProjectOrder(value: unknown) {
26
+ if (!Array.isArray(value)) {
27
+ return []
28
+ }
29
+
30
+ const seen = new Set<string>()
31
+ const projectIds: string[] = []
32
+ for (const entry of value) {
33
+ if (typeof entry !== "string") continue
34
+ const projectId = entry.trim()
35
+ if (!projectId || seen.has(projectId)) continue
36
+ seen.add(projectId)
37
+ projectIds.push(projectId)
38
+ }
39
+
40
+ return projectIds
41
+ }
23
42
 
24
43
  function isSendToStartingProfilingEnabled() {
25
44
  return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
@@ -59,7 +78,6 @@ function getReplayEventPriority(event: StoreEvent) {
59
78
  switch (event.type) {
60
79
  case "project_opened":
61
80
  case "project_removed":
62
- case "sidebar_project_order_set":
63
81
  return 0
64
82
  case "chat_created":
65
83
  return 1
@@ -76,6 +94,8 @@ function getReplayEventPriority(event: StoreEvent) {
76
94
  return 5
77
95
  case "session_token_set":
78
96
  return 6
97
+ case "pending_fork_session_token_set":
98
+ return 6
79
99
  case "turn_cancelled":
80
100
  return 7
81
101
  case "turn_finished":
@@ -112,6 +132,12 @@ function getHistorySnapshot(page: TranscriptPageResult, recentLimit: number): Ch
112
132
  }
113
133
  }
114
134
 
135
+ function getForkedChatTitle(title: string) {
136
+ const trimmed = title.trim()
137
+ if (!trimmed) return "Fork: New Chat"
138
+ return trimmed.startsWith("Fork: ") ? trimmed : `Fork: ${trimmed}`
139
+ }
140
+
115
141
  export class EventStore {
116
142
  readonly dataDir: string
117
143
  readonly state: StoreState = createEmptyState()
@@ -124,7 +150,10 @@ export class EventStore {
124
150
  private readonly queuedMessagesLogPath: string
125
151
  private readonly turnsLogPath: string
126
152
  private readonly transcriptsDir: string
153
+ private readonly sidebarProjectOrderPath: string
127
154
  private legacyMessagesByChatId = new Map<string, TranscriptEntry[]>()
155
+ private legacySidebarProjectOrder: string[] = []
156
+ private sidebarProjectOrder: string[] = []
128
157
  private snapshotHasLegacyMessages = false
129
158
  private cachedTranscript: { chatId: string; entries: TranscriptEntry[] } | null = null
130
159
 
@@ -137,6 +166,7 @@ export class EventStore {
137
166
  this.queuedMessagesLogPath = path.join(this.dataDir, "queued-messages.jsonl")
138
167
  this.turnsLogPath = path.join(this.dataDir, "turns.jsonl")
139
168
  this.transcriptsDir = path.join(this.dataDir, "transcripts")
169
+ this.sidebarProjectOrderPath = path.join(this.dataDir, SIDEBAR_PROJECT_ORDER_FILE)
140
170
  }
141
171
 
142
172
  async initialize() {
@@ -149,6 +179,7 @@ export class EventStore {
149
179
  await this.ensureFile(this.turnsLogPath)
150
180
  await this.loadSnapshot()
151
181
  await this.replayLogs()
182
+ await this.loadSidebarProjectOrder()
152
183
  if (!(await this.hasLegacyTranscriptData()) && await this.shouldCompact()) {
153
184
  await this.compact()
154
185
  }
@@ -197,9 +228,10 @@ export class EventStore {
197
228
  this.state.chatsById.set(chat.id, {
198
229
  ...chat,
199
230
  unread: chat.unread ?? false,
231
+ pendingForkSessionToken: chat.pendingForkSessionToken ?? null,
200
232
  })
201
233
  }
202
- this.state.sidebarProjectOrder = [...(parsed.sidebarProjectOrder ?? [])]
234
+ this.legacySidebarProjectOrder = normalizeSidebarProjectOrder(parsed.sidebarProjectOrder)
203
235
  if (parsed.queuedMessages?.length) {
204
236
  for (const queuedSet of parsed.queuedMessages) {
205
237
  this.state.queuedMessagesByChatId.set(queuedSet.chatId, queuedSet.entries.map((entry) => ({
@@ -225,7 +257,8 @@ export class EventStore {
225
257
  this.state.projectIdsByPath.clear()
226
258
  this.state.chatsById.clear()
227
259
  this.state.queuedMessagesByChatId.clear()
228
- this.state.sidebarProjectOrder = []
260
+ this.sidebarProjectOrder = []
261
+ this.legacySidebarProjectOrder = []
229
262
  this.cachedTranscript = null
230
263
  }
231
264
 
@@ -234,6 +267,86 @@ export class EventStore {
234
267
  this.snapshotHasLegacyMessages = false
235
268
  }
236
269
 
270
+ private async loadSidebarProjectOrder() {
271
+ const file = Bun.file(this.sidebarProjectOrderPath)
272
+ if (await file.exists()) {
273
+ try {
274
+ const text = await file.text()
275
+ if (!text.trim()) {
276
+ this.sidebarProjectOrder = []
277
+ return
278
+ }
279
+ this.sidebarProjectOrder = normalizeSidebarProjectOrder(JSON.parse(text))
280
+ } catch (error) {
281
+ console.warn(`${LOG_PREFIX} Failed to load ${SIDEBAR_PROJECT_ORDER_FILE}, ignoring saved order:`, error)
282
+ this.sidebarProjectOrder = []
283
+ }
284
+ return
285
+ }
286
+
287
+ const legacySidebarProjectOrder = await this.loadLegacySidebarProjectOrder()
288
+ this.sidebarProjectOrder = legacySidebarProjectOrder
289
+ if (legacySidebarProjectOrder.length > 0) {
290
+ await this.writeSidebarProjectOrderFile(legacySidebarProjectOrder)
291
+ }
292
+ }
293
+
294
+ private async loadLegacySidebarProjectOrder() {
295
+ const fromProjectsLog = await this.readLegacySidebarProjectOrderFromProjectsLog()
296
+ if (fromProjectsLog.length > 0) {
297
+ return fromProjectsLog
298
+ }
299
+ return [...this.legacySidebarProjectOrder]
300
+ }
301
+
302
+ private async readLegacySidebarProjectOrderFromProjectsLog() {
303
+ const file = Bun.file(this.projectsLogPath)
304
+ if (!(await file.exists())) return []
305
+
306
+ const text = await file.text()
307
+ if (!text.trim()) return []
308
+
309
+ const lines = text.split("\n")
310
+ let lastNonEmpty = -1
311
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
312
+ if (lines[index].trim()) {
313
+ lastNonEmpty = index
314
+ break
315
+ }
316
+ }
317
+
318
+ let projectIds: string[] = []
319
+ for (let index = 0; index < lines.length; index += 1) {
320
+ const line = lines[index].trim()
321
+ if (!line) continue
322
+ try {
323
+ const event = JSON.parse(line) as {
324
+ v?: number
325
+ type?: string
326
+ projectIds?: unknown
327
+ }
328
+ if (event.v !== STORE_VERSION || event.type !== "sidebar_project_order_set") {
329
+ continue
330
+ }
331
+ projectIds = normalizeSidebarProjectOrder(event.projectIds)
332
+ } catch (error) {
333
+ if (index === lastNonEmpty) {
334
+ console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(this.projectsLogPath)} while migrating sidebar order`)
335
+ return projectIds
336
+ }
337
+ console.warn(`${LOG_PREFIX} Failed to migrate sidebar order from ${path.basename(this.projectsLogPath)}:`, error)
338
+ return []
339
+ }
340
+ }
341
+
342
+ return projectIds
343
+ }
344
+
345
+ private async writeSidebarProjectOrderFile(projectIds: string[]) {
346
+ await mkdir(this.dataDir, { recursive: true })
347
+ await writeFile(this.sidebarProjectOrderPath, `${JSON.stringify(projectIds, null, 2)}\n`, "utf8")
348
+ }
349
+
237
350
  private async replayLogs() {
238
351
  if (this.storageReset) return
239
352
  const replayEvents = [
@@ -283,6 +396,9 @@ export class EventStore {
283
396
  await this.clearStorage()
284
397
  return []
285
398
  }
399
+ if ((event as { type?: unknown }).type === "sidebar_project_order_set") {
400
+ continue
401
+ }
286
402
  parsedEvents.push({
287
403
  event: event as StoreEvent,
288
404
  sourceIndex,
@@ -325,10 +441,6 @@ export class EventStore {
325
441
  this.state.projectIdsByPath.delete(project.localPath)
326
442
  break
327
443
  }
328
- case "sidebar_project_order_set": {
329
- this.state.sidebarProjectOrder = [...event.projectIds]
330
- break
331
- }
332
444
  case "chat_created": {
333
445
  const chat = {
334
446
  id: event.chatId,
@@ -340,6 +452,7 @@ export class EventStore {
340
452
  provider: null,
341
453
  planMode: false,
342
454
  sessionToken: null,
455
+ pendingForkSessionToken: null,
343
456
  hasMessages: false,
344
457
  lastTurnOutcome: null,
345
458
  }
@@ -452,6 +565,13 @@ export class EventStore {
452
565
  chat.updatedAt = event.timestamp
453
566
  break
454
567
  }
568
+ case "pending_fork_session_token_set": {
569
+ const chat = this.state.chatsById.get(event.chatId)
570
+ if (!chat) break
571
+ chat.pendingForkSessionToken = event.pendingForkSessionToken
572
+ chat.updatedAt = event.timestamp
573
+ break
574
+ }
455
575
  }
456
576
  }
457
577
 
@@ -541,7 +661,7 @@ export class EventStore {
541
661
  })
542
662
 
543
663
  const uniqueProjectIds = [...new Set(validProjectIds)]
544
- const current = this.state.sidebarProjectOrder
664
+ const current = this.sidebarProjectOrder
545
665
  if (
546
666
  uniqueProjectIds.length === current.length
547
667
  && uniqueProjectIds.every((projectId, index) => current[index] === projectId)
@@ -549,13 +669,11 @@ export class EventStore {
549
669
  return
550
670
  }
551
671
 
552
- const event: ProjectEvent = {
553
- v: STORE_VERSION,
554
- type: "sidebar_project_order_set",
555
- timestamp: Date.now(),
556
- projectIds: uniqueProjectIds,
557
- }
558
- await this.append(this.projectsLogPath, event)
672
+ this.writeChain = this.writeChain.then(async () => {
673
+ await this.writeSidebarProjectOrderFile(uniqueProjectIds)
674
+ this.sidebarProjectOrder = [...uniqueProjectIds]
675
+ })
676
+ return this.writeChain
559
677
  }
560
678
 
561
679
  async createChat(projectId: string) {
@@ -576,6 +694,50 @@ export class EventStore {
576
694
  return this.state.chatsById.get(chatId)!
577
695
  }
578
696
 
697
+ async forkChat(sourceChatId: string) {
698
+ const sourceChat = this.requireChat(sourceChatId)
699
+ const sourceSessionToken = sourceChat.sessionToken ?? sourceChat.pendingForkSessionToken ?? null
700
+ if (!sourceChat.provider || !sourceSessionToken) {
701
+ throw new Error("Chat cannot be forked")
702
+ }
703
+
704
+ const chatId = crypto.randomUUID()
705
+ const createdAt = Date.now()
706
+ const createEvent: ChatEvent = {
707
+ v: STORE_VERSION,
708
+ type: "chat_created",
709
+ timestamp: createdAt,
710
+ chatId,
711
+ projectId: sourceChat.projectId,
712
+ title: getForkedChatTitle(sourceChat.title),
713
+ }
714
+ await this.append(this.chatsLogPath, createEvent)
715
+ await this.setChatProvider(chatId, sourceChat.provider)
716
+ await this.setPlanMode(chatId, sourceChat.planMode)
717
+ await this.setPendingForkSessionToken(chatId, sourceSessionToken)
718
+
719
+ const sourceEntries = this.getMessages(sourceChatId)
720
+ if (sourceEntries.length > 0) {
721
+ const transcriptPath = this.transcriptPath(chatId)
722
+ const payload = sourceEntries.map((entry) => JSON.stringify(entry)).join("\n")
723
+ this.writeChain = this.writeChain.then(async () => {
724
+ await mkdir(this.transcriptsDir, { recursive: true })
725
+ await writeFile(transcriptPath, `${payload}\n`, "utf8")
726
+ const chat = this.state.chatsById.get(chatId)
727
+ if (chat) {
728
+ chat.hasMessages = true
729
+ chat.updatedAt = Math.max(chat.updatedAt, createdAt)
730
+ }
731
+ if (this.cachedTranscript?.chatId === chatId) {
732
+ this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(sourceEntries) }
733
+ }
734
+ })
735
+ await this.writeChain
736
+ }
737
+
738
+ return this.state.chatsById.get(chatId)!
739
+ }
740
+
579
741
  async renameChat(chatId: string, title: string) {
580
742
  const trimmed = title.trim()
581
743
  if (!trimmed) return
@@ -810,6 +972,19 @@ export class EventStore {
810
972
  await this.append(this.turnsLogPath, event)
811
973
  }
812
974
 
975
+ async setPendingForkSessionToken(chatId: string, pendingForkSessionToken: string | null) {
976
+ const chat = this.requireChat(chatId)
977
+ if ((chat.pendingForkSessionToken ?? null) === pendingForkSessionToken) return
978
+ const event: TurnEvent = {
979
+ v: STORE_VERSION,
980
+ type: "pending_fork_session_token_set",
981
+ timestamp: Date.now(),
982
+ chatId,
983
+ pendingForkSessionToken,
984
+ }
985
+ await this.append(this.turnsLogPath, event)
986
+ }
987
+
813
988
  getProject(projectId: string) {
814
989
  const project = this.state.projectsById.get(projectId)
815
990
  if (!project || project.deletedAt) return null
@@ -830,6 +1005,10 @@ export class EventStore {
830
1005
  return chat
831
1006
  }
832
1007
 
1008
+ getSidebarProjectOrder() {
1009
+ return [...this.sidebarProjectOrder]
1010
+ }
1011
+
833
1012
  private getMessagesPageFromEntries(entries: TranscriptEntry[], limit: number, beforeIndex?: number): TranscriptPageResult {
834
1013
  if (entries.length === 0) {
835
1014
  return { entries: [], hasOlder: false, olderCursor: null }
@@ -961,7 +1140,6 @@ export class EventStore {
961
1140
  v: STORE_VERSION,
962
1141
  generatedAt: Date.now(),
963
1142
  projects: this.listProjects().map((project) => ({ ...project })),
964
- sidebarProjectOrder: [...this.state.sidebarProjectOrder],
965
1143
  chats: [...this.state.chatsById.values()]
966
1144
  .filter((chat) => !chat.deletedAt)
967
1145
  .map((chat) => ({ ...chat })),
@@ -15,6 +15,7 @@ export interface ChatRecord {
15
15
  provider: AgentProvider | null
16
16
  planMode: boolean
17
17
  sessionToken: string | null
18
+ pendingForkSessionToken?: string | null
18
19
  hasMessages?: boolean
19
20
  lastMessageAt?: number
20
21
  lastTurnOutcome: "success" | "failed" | "cancelled" | null
@@ -25,7 +26,6 @@ export interface StoreState {
25
26
  projectIdsByPath: Map<string, string>
26
27
  chatsById: Map<string, ChatRecord>
27
28
  queuedMessagesByChatId: Map<string, QueuedChatMessage[]>
28
- sidebarProjectOrder: string[]
29
29
  }
30
30
 
31
31
  export interface SnapshotFile {
@@ -50,11 +50,6 @@ export type ProjectEvent = {
50
50
  type: "project_removed"
51
51
  timestamp: number
52
52
  projectId: string
53
- } | {
54
- v: 2
55
- type: "sidebar_project_order_set"
56
- timestamp: number
57
- projectIds: string[]
58
53
  }
59
54
 
60
55
  export type ChatEvent =
@@ -158,6 +153,13 @@ export type TurnEvent =
158
153
  chatId: string
159
154
  sessionToken: string | null
160
155
  }
156
+ | {
157
+ v: 2
158
+ type: "pending_fork_session_token_set"
159
+ timestamp: number
160
+ chatId: string
161
+ pendingForkSessionToken: string | null
162
+ }
161
163
 
162
164
  export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | QueuedMessageEvent | TurnEvent
163
165
 
@@ -167,7 +169,6 @@ export function createEmptyState(): StoreState {
167
169
  projectIdsByPath: new Map(),
168
170
  chatsById: new Map(),
169
171
  queuedMessagesByChatId: new Map(),
170
- sidebarProjectOrder: [],
171
172
  }
172
173
  }
173
174
 
@@ -3,6 +3,7 @@ import {
3
3
  codexServiceTierFromModelOptions,
4
4
  normalizeClaudeModelOptions,
5
5
  normalizeCodexModelOptions,
6
+ normalizeServerModel,
6
7
  } from "./provider-catalog"
7
8
  import { resolveClaudeApiModelId } from "../shared/types"
8
9
 
@@ -55,6 +56,11 @@ describe("provider catalog normalization", () => {
55
56
  expect(codexServiceTierFromModelOptions(normalized)).toBe("fast")
56
57
  })
57
58
 
59
+ test("normalizes server model ids through the shared alias catalog", () => {
60
+ expect(normalizeServerModel("claude", "opus")).toBe("claude-opus-4-7")
61
+ expect(normalizeServerModel("codex", "gpt-5-codex")).toBe("gpt-5.3-codex")
62
+ })
63
+
58
64
  test("resolves Claude API model ids for 1m context window", () => {
59
65
  expect(resolveClaudeApiModelId("claude-opus-4-7", "1m")).toBe("claude-opus-4-7[1m]")
60
66
  expect(resolveClaudeApiModelId("claude-sonnet-4-6", "200k")).toBe("claude-sonnet-4-6")
@@ -13,6 +13,7 @@ import {
13
13
  DEFAULT_CODEX_MODEL_OPTIONS,
14
14
  PROVIDERS,
15
15
  normalizeClaudeContextWindow,
16
+ normalizeProviderModelId,
16
17
  isClaudeReasoningEffort,
17
18
  isCodexReasoningEffort,
18
19
  } from "../shared/types"
@@ -43,8 +44,9 @@ export function getServerProviderCatalog(provider: AgentProvider): ProviderCatal
43
44
 
44
45
  export function normalizeServerModel(provider: AgentProvider, model?: string): string {
45
46
  const catalog = getServerProviderCatalog(provider)
46
- if (model && catalog.models.some((candidate) => candidate.id === model)) {
47
- return model
47
+ const normalizedModel = normalizeProviderModelId(provider, model, catalog.defaultModel)
48
+ if (catalog.models.some((candidate) => candidate.id === normalizedModel)) {
49
+ return normalizedModel
48
50
  }
49
51
  return catalog.defaultModel
50
52
  }