ReviewOS

also looking at this

stacks/uplink

feat: let a text switch one thread between Claude and Codex

#52
Merged glennmichael123 wants to merge feat/per-thread-engine into main
16 files +316 -27

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Models/Conversation.tsmodified+12-0
Changes to app/Models/Conversation.ts
@@ -70,5 +70,17 @@ export default defineModel({
7070 validation: { rule: schema.string() },
7171 factory: () => '',
7272 },
73
74 /**
75 * The engine this thread was switched to, or null to follow whatever the
76 * installation is set to. Null is the point: a thread that never asked
77 * keeps tracking the menubar's picker, so changing it there still works.
78 */
79 engine: {
80 order: 8,
81 fillable: true,
82 validation: { rule: schema.enum(['claude', 'codex']) },
83 factory: () => 'claude',
84 },
7385 },
7486} as const)
app/Models/Run.tsmodified+12-0
Changes to app/Models/Run.ts
@@ -110,5 +110,17 @@ export default defineModel({
110110 validation: { rule: schema.number() },
111111 factory: () => Date.now(),
112112 },
113
114 /**
115 * Which agent answered. Nullable rather than defaulted: rows written
116 * before a thread could choose predate the question, and calling them all
117 * Claude would be a guess recorded as a fact.
118 */
119 engine: {
120 order: 14,
121 fillable: true,
122 validation: { rule: schema.enum(['claude', 'codex']) },
123 factory: faker => faker.helpers.arrayElement(['claude', 'codex']),
124 },
113125 },
114126} as const)
app/Uplink/model-store.tsmodified+2-0
Changes to app/Uplink/model-store.ts
@@ -38,6 +38,7 @@ export class ModelStore implements Store {
3838 cwd: row.cwd ?? null,
3939 lastActiveAt: row.last_active_at ?? null,
4040 moreText: row.more_text ?? null,
41 engine: row.engine ?? null,
4142 }
4243 }
4344
@@ -49,6 +50,7 @@ export class ModelStore implements Store {
4950 cwd: conversation.cwd,
5051 lastActiveAt: conversation.lastActiveAt,
5152 moreText: conversation.moreText,
53 engine: conversation.engine,
5254 }
5355 const existing = await Conversation.where('chat_guid', conversation.chatGuid).first() as { update: (v: object) => Promise<unknown> } | undefined
5456 if (existing)
app/Uplink/sqlite-store.tsmodified+11-5
Changes to app/Uplink/sqlite-store.ts
@@ -37,7 +37,7 @@ export interface RunRow extends RunRecord {
3737 * old it is, and so the day a change needs more than a new column - a backfill,
3838 * a rename - has a version to key off.
3939 */
40const SCHEMA_VERSION = 1
40const SCHEMA_VERSION = 2
4141
4242export interface TableSpec {
4343 name: string
@@ -78,6 +78,7 @@ export const TABLES: TableSpec[] = [
7878 cwd: 'TEXT',
7979 last_active_at: 'INTEGER',
8080 more_text: 'TEXT',
81 engine: 'TEXT',
8182 },
8283 },
8384 {
@@ -97,6 +98,7 @@ export const TABLES: TableSpec[] = [
9798 last_activity: 'TEXT',
9899 started_at: 'INTEGER',
99100 finished_at: 'INTEGER',
101 engine: 'TEXT',
100102 },
101103 },
102104]
@@ -115,6 +117,7 @@ export const RUN_COLUMNS: Record<keyof RunRecord, string> = {
115117 lastActivity: 'last_activity',
116118 startedAt: 'started_at',
117119 finishedAt: 'finished_at',
120 engine: 'engine',
118121}
119122
120123/**
@@ -236,17 +239,19 @@ export class SqliteStore implements Store {
236239 cwd: row.cwd,
237240 lastActiveAt: row.last_active_at,
238241 moreText: row.more_text,
242 engine: row.engine,
239243 }
240244 }
241245
242246 async saveConversation(c: ConversationRecord): Promise<void> {
243247 this.db.query(`
244 INSERT INTO conversations (chat_guid, handle, service, session_id, cwd, last_active_at, more_text)
245 VALUES (?, ?, ?, ?, ?, ?, ?)
248 INSERT INTO conversations (chat_guid, handle, service, session_id, cwd, last_active_at, more_text, engine)
249 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
246250 ON CONFLICT(chat_guid) DO UPDATE SET
247251 handle = excluded.handle, service = excluded.service, session_id = excluded.session_id,
248 cwd = excluded.cwd, last_active_at = excluded.last_active_at, more_text = excluded.more_text
249 `).run(c.chatGuid, c.handle, c.service, c.sessionId, c.cwd, c.lastActiveAt, c.moreText)
252 cwd = excluded.cwd, last_active_at = excluded.last_active_at, more_text = excluded.more_text,
253 engine = excluded.engine
254 `).run(c.chatGuid, c.handle, c.service, c.sessionId, c.cwd, c.lastActiveAt, c.moreText, c.engine)
250255 }
251256
252257 async createRun(run: RunRecord): Promise<number> {
@@ -283,6 +288,7 @@ export class SqliteStore implements Store {
283288 lastActivity: row.last_activity,
284289 startedAt: row.started_at,
285290 finishedAt: row.finished_at,
291 engine: row.engine,
286292 }))
287293 }
288294}
app/Uplink/store.tsmodified+10-0
Changes to app/Uplink/store.ts
@@ -4,6 +4,8 @@
44 * the model-backed store in `model-store.ts`, tests use `MemoryStore`.
55 */
66
7import type { EngineId } from './engine'
8
79export type RunStatus = 'queued' | 'running' | 'done' | 'failed' | 'stopped'
810
911export interface ConversationRecord {
@@ -15,6 +17,12 @@ export interface ConversationRecord {
1517 lastActiveAt: number | null
1618 /** The unsent tail of the last long reply, released by "more". */
1719 moreText: string | null
20 /**
21 * The engine this thread was switched to, or null to follow the
22 * installation's setting. Null rather than a default so a thread that never
23 * asked still tracks the menubar's picker.
24 */
25 engine: EngineId | null
1826}
1927
2028export interface RunRecord {
@@ -31,6 +39,8 @@ export interface RunRecord {
3139 lastActivity?: string | null
3240 startedAt?: number | null
3341 finishedAt?: number | null
42 /** Which agent answered, recorded per run because a thread can switch. */
43 engine?: EngineId | null
3444}
3545
3646export interface Store {
app/Uplink/uplink.tsmodified+1-0
Changes to app/Uplink/uplink.ts
@@ -301,6 +301,7 @@ export class Uplink {
301301 cwd: null,
302302 lastActiveAt: null,
303303 moreText: null,
304 engine: null,
304305 }
305306 }
306307
database/migrations/1789486698560-alter-conversations-columns.sqladded+3-0
Changes to database/migrations/1789486698560-alter-conversations-columns.sql
@@ -0,0 +1,3 @@
1-- qb:generated
2-- @generated by `buddy migrate:regenerate` - edits will be overwritten
3ALTER TABLE "conversations" ADD COLUMN "engine" TEXT CHECK ("engine" IN ('claude', 'codex'));
database/migrations/1789486698561-alter-runs-columns.sqladded+3-0
Changes to database/migrations/1789486698561-alter-runs-columns.sql
@@ -0,0 +1,3 @@
1-- qb:generated
2-- @generated by `buddy migrate:regenerate` - edits will be overwritten
3ALTER TABLE "runs" ADD COLUMN "engine" TEXT CHECK ("engine" IN ('claude', 'codex'));
storage/framework/database/model-snapshot.sqlite.jsonmodified+26-2
Changes to storage/framework/database/model-snapshot.sqlite.json
@@ -90,6 +90,18 @@
9090 "isUnique": false,
9191 "isNullable": true,
9292 "hasDefault": false
93 },
94 {
95 "name": "engine",
96 "type": "enum",
97 "isPrimaryKey": false,
98 "isUnique": false,
99 "isNullable": true,
100 "hasDefault": false,
101 "enumValues": [
102 "claude",
103 "codex"
104 ]
93105 }
94106 ],
95107 "indexes": [
@@ -257,6 +269,18 @@
257269 "isUnique": false,
258270 "isNullable": true,
259271 "hasDefault": false
272 },
273 {
274 "name": "engine",
275 "type": "enum",
276 "isPrimaryKey": false,
277 "isUnique": false,
278 "isNullable": true,
279 "hasDefault": false,
280 "enumValues": [
281 "claude",
282 "codex"
283 ]
260284 }
261285 ],
262286 "indexes": []
@@ -392,7 +416,7 @@
392416 }
393417 ]
394418 },
395 "hash": "cf904d0837380746ef9f95409635f2960b57dbac539b69229ea1da12f911d4b5",
419 "hash": "3273a7f2b7ad412f972eb4decd23ad15b2cb9ac86d757bd5f520ed81f2e3d919",
396420 "dialect": "sqlite",
397 "updatedAt": "2026-09-28T21:27:50.923Z"
421 "updatedAt": "2026-09-29T15:42:55.226Z"
398422}
tests/unit/uplink/sqlite-store.test.tsmodified+1-1
Changes to tests/unit/uplink/sqlite-store.test.ts
@@ -30,7 +30,7 @@ describe('SqliteStore', () => {
3030 await store.setCursor(42)
3131 expect(await store.cursor()).toBe(42)
3232
33 const conversation = { chatGuid: 'g', handle: ME, service: 'iMessage', sessionId: 's', cwd: '/tmp', lastActiveAt: 5, moreText: 'x'.repeat(3000) }
33 const conversation = { chatGuid: 'g', handle: ME, service: 'iMessage', sessionId: 's', cwd: '/tmp', lastActiveAt: 5, moreText: 'x'.repeat(3000), engine: null }
3434 await store.saveConversation(conversation)
3535 await store.saveConversation({ ...conversation, sessionId: 's2' })
3636 expect(await store.conversation('g')).toEqual({ ...conversation, sessionId: 's2' })