kanna-code 0.32.3 → 0.32.4

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.
@@ -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
@@ -124,7 +142,10 @@ export class EventStore {
124
142
  private readonly queuedMessagesLogPath: string
125
143
  private readonly turnsLogPath: string
126
144
  private readonly transcriptsDir: string
145
+ private readonly sidebarProjectOrderPath: string
127
146
  private legacyMessagesByChatId = new Map<string, TranscriptEntry[]>()
147
+ private legacySidebarProjectOrder: string[] = []
148
+ private sidebarProjectOrder: string[] = []
128
149
  private snapshotHasLegacyMessages = false
129
150
  private cachedTranscript: { chatId: string; entries: TranscriptEntry[] } | null = null
130
151
 
@@ -137,6 +158,7 @@ export class EventStore {
137
158
  this.queuedMessagesLogPath = path.join(this.dataDir, "queued-messages.jsonl")
138
159
  this.turnsLogPath = path.join(this.dataDir, "turns.jsonl")
139
160
  this.transcriptsDir = path.join(this.dataDir, "transcripts")
161
+ this.sidebarProjectOrderPath = path.join(this.dataDir, SIDEBAR_PROJECT_ORDER_FILE)
140
162
  }
141
163
 
142
164
  async initialize() {
@@ -149,6 +171,7 @@ export class EventStore {
149
171
  await this.ensureFile(this.turnsLogPath)
150
172
  await this.loadSnapshot()
151
173
  await this.replayLogs()
174
+ await this.loadSidebarProjectOrder()
152
175
  if (!(await this.hasLegacyTranscriptData()) && await this.shouldCompact()) {
153
176
  await this.compact()
154
177
  }
@@ -199,7 +222,7 @@ export class EventStore {
199
222
  unread: chat.unread ?? false,
200
223
  })
201
224
  }
202
- this.state.sidebarProjectOrder = [...(parsed.sidebarProjectOrder ?? [])]
225
+ this.legacySidebarProjectOrder = normalizeSidebarProjectOrder(parsed.sidebarProjectOrder)
203
226
  if (parsed.queuedMessages?.length) {
204
227
  for (const queuedSet of parsed.queuedMessages) {
205
228
  this.state.queuedMessagesByChatId.set(queuedSet.chatId, queuedSet.entries.map((entry) => ({
@@ -225,7 +248,8 @@ export class EventStore {
225
248
  this.state.projectIdsByPath.clear()
226
249
  this.state.chatsById.clear()
227
250
  this.state.queuedMessagesByChatId.clear()
228
- this.state.sidebarProjectOrder = []
251
+ this.sidebarProjectOrder = []
252
+ this.legacySidebarProjectOrder = []
229
253
  this.cachedTranscript = null
230
254
  }
231
255
 
@@ -234,6 +258,86 @@ export class EventStore {
234
258
  this.snapshotHasLegacyMessages = false
235
259
  }
236
260
 
261
+ private async loadSidebarProjectOrder() {
262
+ const file = Bun.file(this.sidebarProjectOrderPath)
263
+ if (await file.exists()) {
264
+ try {
265
+ const text = await file.text()
266
+ if (!text.trim()) {
267
+ this.sidebarProjectOrder = []
268
+ return
269
+ }
270
+ this.sidebarProjectOrder = normalizeSidebarProjectOrder(JSON.parse(text))
271
+ } catch (error) {
272
+ console.warn(`${LOG_PREFIX} Failed to load ${SIDEBAR_PROJECT_ORDER_FILE}, ignoring saved order:`, error)
273
+ this.sidebarProjectOrder = []
274
+ }
275
+ return
276
+ }
277
+
278
+ const legacySidebarProjectOrder = await this.loadLegacySidebarProjectOrder()
279
+ this.sidebarProjectOrder = legacySidebarProjectOrder
280
+ if (legacySidebarProjectOrder.length > 0) {
281
+ await this.writeSidebarProjectOrderFile(legacySidebarProjectOrder)
282
+ }
283
+ }
284
+
285
+ private async loadLegacySidebarProjectOrder() {
286
+ const fromProjectsLog = await this.readLegacySidebarProjectOrderFromProjectsLog()
287
+ if (fromProjectsLog.length > 0) {
288
+ return fromProjectsLog
289
+ }
290
+ return [...this.legacySidebarProjectOrder]
291
+ }
292
+
293
+ private async readLegacySidebarProjectOrderFromProjectsLog() {
294
+ const file = Bun.file(this.projectsLogPath)
295
+ if (!(await file.exists())) return []
296
+
297
+ const text = await file.text()
298
+ if (!text.trim()) return []
299
+
300
+ const lines = text.split("\n")
301
+ let lastNonEmpty = -1
302
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
303
+ if (lines[index].trim()) {
304
+ lastNonEmpty = index
305
+ break
306
+ }
307
+ }
308
+
309
+ let projectIds: string[] = []
310
+ for (let index = 0; index < lines.length; index += 1) {
311
+ const line = lines[index].trim()
312
+ if (!line) continue
313
+ try {
314
+ const event = JSON.parse(line) as {
315
+ v?: number
316
+ type?: string
317
+ projectIds?: unknown
318
+ }
319
+ if (event.v !== STORE_VERSION || event.type !== "sidebar_project_order_set") {
320
+ continue
321
+ }
322
+ projectIds = normalizeSidebarProjectOrder(event.projectIds)
323
+ } catch (error) {
324
+ if (index === lastNonEmpty) {
325
+ console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(this.projectsLogPath)} while migrating sidebar order`)
326
+ return projectIds
327
+ }
328
+ console.warn(`${LOG_PREFIX} Failed to migrate sidebar order from ${path.basename(this.projectsLogPath)}:`, error)
329
+ return []
330
+ }
331
+ }
332
+
333
+ return projectIds
334
+ }
335
+
336
+ private async writeSidebarProjectOrderFile(projectIds: string[]) {
337
+ await mkdir(this.dataDir, { recursive: true })
338
+ await writeFile(this.sidebarProjectOrderPath, `${JSON.stringify(projectIds, null, 2)}\n`, "utf8")
339
+ }
340
+
237
341
  private async replayLogs() {
238
342
  if (this.storageReset) return
239
343
  const replayEvents = [
@@ -283,6 +387,9 @@ export class EventStore {
283
387
  await this.clearStorage()
284
388
  return []
285
389
  }
390
+ if ((event as { type?: unknown }).type === "sidebar_project_order_set") {
391
+ continue
392
+ }
286
393
  parsedEvents.push({
287
394
  event: event as StoreEvent,
288
395
  sourceIndex,
@@ -325,10 +432,6 @@ export class EventStore {
325
432
  this.state.projectIdsByPath.delete(project.localPath)
326
433
  break
327
434
  }
328
- case "sidebar_project_order_set": {
329
- this.state.sidebarProjectOrder = [...event.projectIds]
330
- break
331
- }
332
435
  case "chat_created": {
333
436
  const chat = {
334
437
  id: event.chatId,
@@ -541,7 +644,7 @@ export class EventStore {
541
644
  })
542
645
 
543
646
  const uniqueProjectIds = [...new Set(validProjectIds)]
544
- const current = this.state.sidebarProjectOrder
647
+ const current = this.sidebarProjectOrder
545
648
  if (
546
649
  uniqueProjectIds.length === current.length
547
650
  && uniqueProjectIds.every((projectId, index) => current[index] === projectId)
@@ -549,13 +652,11 @@ export class EventStore {
549
652
  return
550
653
  }
551
654
 
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)
655
+ this.writeChain = this.writeChain.then(async () => {
656
+ await this.writeSidebarProjectOrderFile(uniqueProjectIds)
657
+ this.sidebarProjectOrder = [...uniqueProjectIds]
658
+ })
659
+ return this.writeChain
559
660
  }
560
661
 
561
662
  async createChat(projectId: string) {
@@ -830,6 +931,10 @@ export class EventStore {
830
931
  return chat
831
932
  }
832
933
 
934
+ getSidebarProjectOrder() {
935
+ return [...this.sidebarProjectOrder]
936
+ }
937
+
833
938
  private getMessagesPageFromEntries(entries: TranscriptEntry[], limit: number, beforeIndex?: number): TranscriptPageResult {
834
939
  if (entries.length === 0) {
835
940
  return { entries: [], hasOlder: false, olderCursor: null }
@@ -961,7 +1066,6 @@ export class EventStore {
961
1066
  v: STORE_VERSION,
962
1067
  generatedAt: Date.now(),
963
1068
  projects: this.listProjects().map((project) => ({ ...project })),
964
- sidebarProjectOrder: [...this.state.sidebarProjectOrder],
965
1069
  chats: [...this.state.chatsById.values()]
966
1070
  .filter((chat) => !chat.deletedAt)
967
1071
  .map((chat) => ({ ...chat })),
@@ -25,7 +25,6 @@ export interface StoreState {
25
25
  projectIdsByPath: Map<string, string>
26
26
  chatsById: Map<string, ChatRecord>
27
27
  queuedMessagesByChatId: Map<string, QueuedChatMessage[]>
28
- sidebarProjectOrder: string[]
29
28
  }
30
29
 
31
30
  export interface SnapshotFile {
@@ -50,11 +49,6 @@ export type ProjectEvent = {
50
49
  type: "project_removed"
51
50
  timestamp: number
52
51
  projectId: string
53
- } | {
54
- v: 2
55
- type: "sidebar_project_order_set"
56
- timestamp: number
57
- projectIds: string[]
58
52
  }
59
53
 
60
54
  export type ChatEvent =
@@ -167,7 +161,6 @@ export function createEmptyState(): StoreState {
167
161
  projectIdsByPath: new Map(),
168
162
  chatsById: new Map(),
169
163
  queuedMessagesByChatId: new Map(),
170
- sidebarProjectOrder: [],
171
164
  }
172
165
  }
173
166
 
@@ -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
  }
@@ -26,7 +26,7 @@ describe("read models", () => {
26
26
  lastTurnOutcome: null,
27
27
  })
28
28
 
29
- const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
29
+ const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
30
30
  expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
31
31
  expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
32
32
  expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
@@ -198,9 +198,7 @@ describe("read models", () => {
198
198
  createdAt: 3,
199
199
  updatedAt: 15,
200
200
  })
201
- state.sidebarProjectOrder = ["project-1"]
202
-
203
- const sidebar = deriveSidebarData(state, new Map())
201
+ const sidebar = deriveSidebarData(state, new Map(), { sidebarProjectOrder: ["project-1"] })
204
202
 
205
203
  expect(sidebar.projectGroups.map((group) => group.groupKey)).toEqual(["project-1", "project-2", "project-3"])
206
204
  })
@@ -242,7 +240,7 @@ describe("read models", () => {
242
240
  lastTurnOutcome: null,
243
241
  })
244
242
 
245
- const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
243
+ const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
246
244
 
247
245
  expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
248
246
  expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
@@ -49,8 +49,12 @@ function getSidebarChatBuckets(chats: SidebarChatRow[], nowMs: number) {
49
49
  export function deriveSidebarData(
50
50
  state: StoreState,
51
51
  activeStatuses: Map<string, KannaStatus>,
52
- nowMs = Date.now()
52
+ options?: {
53
+ nowMs?: number
54
+ sidebarProjectOrder?: string[]
55
+ }
53
56
  ): SidebarData {
57
+ const nowMs = options?.nowMs ?? Date.now()
54
58
  const chatsByProjectId = new Map<string, ChatRecord[]>()
55
59
  for (const chat of state.chatsById.values()) {
56
60
  if (chat.deletedAt) continue
@@ -67,7 +71,7 @@ export function deriveSidebarData(
67
71
  const unorderedProjects = allProjects
68
72
  .sort((a, b) => b.updatedAt - a.updatedAt)
69
73
  const projectById = new Map(unorderedProjects.map((project) => [project.id, project]))
70
- const orderedProjects = state.sidebarProjectOrder
74
+ const orderedProjects = (options?.sidebarProjectOrder ?? [])
71
75
  .map((projectId) => projectById.get(projectId))
72
76
  .filter((project): project is NonNullable<typeof project> => Boolean(project))
73
77
  const orderedProjectIds = new Set(orderedProjects.map((project) => project.id))
@@ -63,42 +63,10 @@ function createNamedTunnel(token: string, localUrl: string) {
63
63
  return tunnel
64
64
  }
65
65
 
66
- async function awaitQuickTunnelUrl(tunnel: ShareTunnelProcess) {
67
- return await new Promise<string>((resolve, reject) => {
68
- let settled = false
69
-
70
- const cleanup = () => {
71
- tunnel.off("url", handleUrl)
72
- tunnel.off("error", handleError)
73
- tunnel.off("exit", handleExit)
74
- }
75
-
76
- const settle = (callback: () => void) => {
77
- if (settled) return
78
- settled = true
79
- cleanup()
80
- callback()
81
- }
82
-
83
- const handleUrl = (url: string) => {
84
- settle(() => resolve(normalizePublicUrl(url)))
85
- }
86
-
87
- const handleError = (error: Error) => {
88
- settle(() => reject(error))
89
- }
90
-
91
- const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
92
- settle(() => reject(new Error(`Cloudflare tunnel exited before a public URL was ready (code: ${String(code)}, signal: ${String(signal)})`)))
93
- }
94
-
95
- tunnel.once("url", handleUrl)
96
- tunnel.once("error", handleError)
97
- tunnel.once("exit", handleExit)
98
- })
99
- }
100
-
101
- async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
66
+ async function awaitTunnelReady(
67
+ tunnel: ShareTunnelProcess,
68
+ { expectUrl }: { expectUrl: boolean },
69
+ ) {
102
70
  return await new Promise<string | null>((resolve, reject) => {
103
71
  let settled = false
104
72
 
@@ -121,6 +89,7 @@ async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
121
89
  }
122
90
 
123
91
  const handleConnected = () => {
92
+ if (expectUrl) return
124
93
  settle(() => resolve(null))
125
94
  }
126
95
 
@@ -129,11 +98,14 @@ async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
129
98
  }
130
99
 
131
100
  const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
132
- settle(() => reject(new Error(`Cloudflare tunnel exited before it was ready (code: ${String(code)}, signal: ${String(signal)})`)))
101
+ const readyState = expectUrl ? "a public URL was ready" : "it was ready"
102
+ settle(() => reject(new Error(`Cloudflare tunnel exited before ${readyState} (code: ${String(code)}, signal: ${String(signal)})`)))
133
103
  }
134
104
 
135
105
  tunnel.once("url", handleUrl)
136
- tunnel.once("connected", handleConnected)
106
+ if (!expectUrl) {
107
+ tunnel.once("connected", handleConnected)
108
+ }
137
109
  tunnel.once("error", handleError)
138
110
  tunnel.once("exit", handleExit)
139
111
  })
@@ -145,12 +117,11 @@ export async function startShareTunnel(
145
117
  deps: ShareTunnelDeps = {},
146
118
  ): Promise<StartedShareTunnel> {
147
119
  await ensureCloudflaredInstalled(deps)
148
- const tunnel = isTokenShareMode(shareMode)
120
+ const namedTunnel = isTokenShareMode(shareMode)
121
+ const tunnel = namedTunnel
149
122
  ? (deps.createNamedTunnel ?? createNamedTunnel)(shareMode.token, localUrl)
150
123
  : (deps.createQuickTunnel ?? ((url) => Tunnel.quick(url)))(localUrl)
151
- const publicUrl = isTokenShareMode(shareMode)
152
- ? await awaitNamedTunnelReady(tunnel)
153
- : await awaitQuickTunnelUrl(tunnel)
124
+ const publicUrl = await awaitTunnelReady(tunnel, { expectUrl: !namedTunnel })
154
125
 
155
126
  return {
156
127
  publicUrl,
@@ -863,12 +863,16 @@ describe("ws-router", () => {
863
863
  })
864
864
 
865
865
  const setSidebarProjectOrderCalls: string[][] = []
866
+ let sidebarProjectOrder: string[] = []
866
867
  const router = createWsRouter({
867
868
  store: {
868
869
  state,
870
+ getSidebarProjectOrder() {
871
+ return [...sidebarProjectOrder]
872
+ },
869
873
  async setSidebarProjectOrder(projectIds: string[]) {
870
874
  setSidebarProjectOrderCalls.push(projectIds)
871
- state.sidebarProjectOrder = [...projectIds]
875
+ sidebarProjectOrder = [...projectIds]
872
876
  },
873
877
  } as never,
874
878
  agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
@@ -126,6 +126,12 @@ interface SnapshotComputationCache {
126
126
  }
127
127
  }
128
128
 
129
+ function getSidebarProjectOrder(store: EventStore) {
130
+ return typeof store.getSidebarProjectOrder === "function"
131
+ ? store.getSidebarProjectOrder()
132
+ : []
133
+ }
134
+
129
135
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
130
136
  const payload = JSON.stringify(message)
131
137
  ws.send(payload)
@@ -300,7 +306,9 @@ export function createWsRouter({
300
306
  }
301
307
 
302
308
  const startedAt = performance.now()
303
- const data = deriveSidebarData(store.state, agent.getActiveStatuses())
309
+ const data = deriveSidebarData(store.state, agent.getActiveStatuses(), {
310
+ sidebarProjectOrder: getSidebarProjectOrder(store),
311
+ })
304
312
  if (isSendToStartingProfilingEnabled()) {
305
313
  const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
306
314
  console.log("[kanna/send->starting][server]", JSON.stringify({
@@ -1,5 +1,5 @@
1
1
  import type { ShareMode } from "./share"
2
- import { isShareEnabled } from "./share"
2
+ import { assertNoHostOverride, getShareCliFlag, isShareEnabled } from "./share"
3
3
 
4
4
  export const DEFAULT_DEV_CLIENT_PORT = 5174
5
5
 
@@ -89,14 +89,12 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
89
89
  for (let index = 0; index < args.length; index += 1) {
90
90
  const arg = args[index]
91
91
  if (arg === "--share") {
92
- if (sawHost) throw new Error("--share cannot be used with --host")
93
- if (sawRemote) throw new Error("--share cannot be used with --remote")
92
+ assertNoHostOverride("--share", sawHost, sawRemote)
94
93
  share = "quick"
95
94
  continue
96
95
  }
97
96
  if (arg === "--cloudflared") {
98
- if (sawHost) throw new Error("--cloudflared cannot be used with --host")
99
- if (sawRemote) throw new Error("--cloudflared cannot be used with --remote")
97
+ assertNoHostOverride("--cloudflared", sawHost, sawRemote)
100
98
  const next = args[index + 1]
101
99
  if (!next || next.startsWith("-")) throw new Error("Missing value for --cloudflared")
102
100
  share = { kind: "token", token: next }
@@ -105,7 +103,7 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
105
103
  }
106
104
  if (arg === "--remote") {
107
105
  if (isShareEnabled(share)) {
108
- throw new Error(typeof share === "string" ? "--share cannot be used with --remote" : "--cloudflared cannot be used with --remote")
106
+ throw new Error(`${getShareCliFlag(share)} cannot be used with --remote`)
109
107
  }
110
108
  sawRemote = true
111
109
  backendTargetHost = "127.0.0.1"
@@ -117,7 +115,7 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
117
115
  const next = args[index + 1]
118
116
  if (!next || next.startsWith("-")) continue
119
117
  if (isShareEnabled(share)) {
120
- throw new Error(typeof share === "string" ? "--share cannot be used with --host" : "--cloudflared cannot be used with --host")
118
+ throw new Error(`${getShareCliFlag(share)} cannot be used with --host`)
121
119
  }
122
120
  sawHost = true
123
121
  hosts.add(next)
@@ -3,6 +3,8 @@ export type ShareMode = false | "quick" | {
3
3
  token: string
4
4
  }
5
5
 
6
+ export type ShareCliFlag = "--share" | "--cloudflared"
7
+
6
8
  export function isShareEnabled(share: ShareMode): share is Exclude<ShareMode, false> {
7
9
  return share !== false
8
10
  }
@@ -10,3 +12,16 @@ export function isShareEnabled(share: ShareMode): share is Exclude<ShareMode, fa
10
12
  export function isTokenShareMode(share: ShareMode): share is { kind: "token"; token: string } {
11
13
  return typeof share === "object" && share !== null && share.kind === "token"
12
14
  }
15
+
16
+ export function getShareCliFlag(share: Exclude<ShareMode, false>): ShareCliFlag {
17
+ return isTokenShareMode(share) ? "--cloudflared" : "--share"
18
+ }
19
+
20
+ export function assertNoHostOverride(shareFlag: ShareCliFlag, sawHost: boolean, sawRemote: boolean) {
21
+ if (sawHost) {
22
+ throw new Error(`${shareFlag} cannot be used with --host`)
23
+ }
24
+ if (sawRemote) {
25
+ throw new Error(`${shareFlag} cannot be used with --remote`)
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ normalizeClaudeModelId,
4
+ normalizeCodexModelId,
5
+ supportsClaudeMaxReasoningEffort,
6
+ } from "./types"
7
+
8
+ describe("shared model normalization", () => {
9
+ test("normalizes Claude aliases via the provider catalog", () => {
10
+ expect(normalizeClaudeModelId("opus")).toBe("claude-opus-4-7")
11
+ expect(normalizeClaudeModelId("sonnet")).toBe("claude-sonnet-4-6")
12
+ expect(normalizeClaudeModelId("haiku")).toBe("claude-haiku-4-5-20251001")
13
+ })
14
+
15
+ test("normalizes legacy Codex aliases via the provider catalog", () => {
16
+ expect(normalizeCodexModelId("gpt-5-codex")).toBe("gpt-5.3-codex")
17
+ })
18
+
19
+ test("uses declarative metadata for Claude max-effort support", () => {
20
+ expect(supportsClaudeMaxReasoningEffort("claude-opus-4-7")).toBe(true)
21
+ expect(supportsClaudeMaxReasoningEffort("opus")).toBe(true)
22
+ expect(supportsClaudeMaxReasoningEffort("claude-sonnet-4-6")).toBe(false)
23
+ })
24
+ })