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.
- package/dist/client/assets/index-BYzAMpzV.css +32 -0
- package/dist/client/assets/index-ab3JkqZ2.js +2608 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +88 -0
- package/src/server/agent.ts +44 -3
- package/src/server/auth.test.ts +119 -2
- package/src/server/auth.ts +46 -11
- package/src/server/cli-runtime.test.ts +4 -0
- package/src/server/cli-runtime.ts +11 -7
- package/src/server/codex-app-server-protocol.ts +12 -0
- package/src/server/codex-app-server.test.ts +32 -0
- package/src/server/codex-app-server.ts +17 -3
- package/src/server/diff-store.ts +0 -9
- package/src/server/event-store.test.ts +100 -2
- package/src/server/event-store.ts +194 -16
- package/src/server/events.ts +8 -7
- package/src/server/provider-catalog.test.ts +6 -0
- package/src/server/provider-catalog.ts +4 -2
- package/src/server/read-models.test.ts +63 -5
- package/src/server/read-models.ts +21 -2
- package/src/server/server.ts +8 -1
- package/src/server/share.ts +13 -42
- package/src/server/ws-router.test.ts +139 -2
- package/src/server/ws-router.ts +16 -1
- package/src/shared/dev-ports.ts +5 -7
- package/src/shared/protocol.ts +1 -0
- package/src/shared/share.ts +15 -0
- package/src/shared/types.test.ts +24 -0
- package/src/shared/types.ts +61 -25
- package/dist/client/assets/index-B3EUEMfY.css +0 -32
- package/dist/client/assets/index-BVeR75Xr.js +0 -2598
|
@@ -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.
|
|
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> {
|
package/src/server/diff-store.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 })),
|
package/src/server/events.ts
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
}
|