@kabmo-labs/kabmo 0.5.3 → 0.6.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.
Files changed (117) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-9js9mrmj.js → index-ae9z0yvm.js} +704 -704
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +5 -8
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/desktop/servers.test.ts +73 -0
  44. package/src/client/hooks/desktop/servers.ts +126 -0
  45. package/src/client/hooks/settings/model-roles.ts +156 -0
  46. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  47. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  48. package/src/client/hooks/workspace/keybindings.ts +44 -0
  49. package/src/client/hooks/workspace/open-panel.ts +42 -0
  50. package/src/client/lib/desktop/bridge.ts +158 -0
  51. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  52. package/src/client/lib/desktop/titlebar.ts +97 -0
  53. package/src/client/lib/plugins/slots.test.ts +54 -0
  54. package/src/client/lib/plugins/slots.ts +37 -1
  55. package/src/server/db.server.test.ts +44 -3
  56. package/src/server/db.server.ts +26 -2
  57. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  58. package/src/server/lib/db/schema.ts +3 -0
  59. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  60. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  61. package/src/server/lib/model-roles/mock.server.ts +40 -0
  62. package/src/server/lib/model-roles/schema.server.ts +36 -0
  63. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  64. package/src/server/lib/model-roles/store.server.ts +188 -0
  65. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  66. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  67. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  68. package/src/server/lib/omp/rpc/manager.ts +18 -13
  69. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  70. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  71. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  72. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  73. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  74. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  75. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  76. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  77. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  78. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  79. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  80. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  81. package/src/server/lib/panels/compat.server.test.ts +4 -4
  82. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  83. package/src/server/lib/panels/registry.server.test.ts +4 -4
  84. package/src/server/lib/panels/state.server.test.ts +4 -4
  85. package/src/server/lib/panels/sync.server.test.ts +4 -4
  86. package/src/server/lib/plugins/background.test.ts +2 -2
  87. package/src/server/lib/plugins/e2e.test.ts +2 -2
  88. package/src/server/lib/plugins/platform.test.ts +2 -2
  89. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  90. package/src/server/lib/queue/delivery.server.ts +11 -2
  91. package/src/server/lib/updates/shell-release.test.ts +184 -0
  92. package/src/server/lib/updates/shell-release.ts +215 -0
  93. package/src/server/lib/waiting/store.server.test.ts +2 -2
  94. package/src/server/plugins/ssr.ts +13 -10
  95. package/src/server/routes/chat/stream.test.ts +4 -4
  96. package/src/server/routes/models/roles.ts +73 -14
  97. package/src/server/routes/panels/css.test.ts +4 -4
  98. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  99. package/src/server/routes/plugins/routes.test.ts +4 -4
  100. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  101. package/src/server/routes/updates/android.ts +2 -2
  102. package/src/server/routes/updates/index.ts +2 -0
  103. package/src/server/routes/updates/ios.ts +26 -0
  104. package/src/server/routes/waiting/routes.test.ts +2 -2
  105. package/src/shared/lib/models/server-cache.ts +11 -0
  106. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  107. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  108. package/src/shared/lib/updates/shell-ua.ts +41 -20
  109. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  110. package/src/shared/lib/workspace/panel-open.ts +76 -0
  111. package/src/shared/types/model-roles.ts +54 -0
  112. package/src/test-support/isolated-db.ts +17 -5
  113. package/dist/client/static/js/index-3qamd33d.css +0 -1
  114. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  115. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  116. package/src/server/lib/updates/android-release.test.ts +0 -138
  117. package/src/server/lib/updates/android-release.ts +0 -183
@@ -18,7 +18,7 @@
18
18
  * rpc-mode dispatches for a `prompt` frame BEFORE that frame is forwarded as a
19
19
  * turn — so the request can be sent at any point in a run and never becomes
20
20
  * part of the conversation. Verified against omp 18.3.0 — the ack returns
21
- * `{agentInvoked:false}` immediately, the generation lands ~3-4s later as
21
+ * `{agentInvoked:false}` immediately, the generation lands seconds later as
22
22
  * `session_info_update` + `command_output`, and no entry is written to the
23
23
  * transcript.
24
24
  *
@@ -30,13 +30,28 @@
30
30
  * on every attempt rather than cached, so a rename made in another tab is
31
31
  * respected.
32
32
  *
33
- * ONE ATTEMPT PER CONVERSATION, RETRIED ONCE INSIDE THE FIRST RUN. The title
34
- * is derived from the FIRST run and never re-asked after it. omp's `/rename`
35
- * derives its title from the newest turns (and `generateRenameTitle` reserves a
36
- * fresh title revision, which cancels any generation still in flight), so a
37
- * second attempt after turn two would name the session after whatever the
38
- * conversation had become by then — and could even discard a slow first
39
- * generation.
33
+ * ## The title runs on the SESSION's model, not omp's `tiny` role
34
+ *
35
+ * This is the reason auto-titling produced no name at all on a real install:
36
+ * omp names a session with its `tiny` ROLE (`getTitleModels` →
37
+ * `roleCandidatePool("tiny")`) and only appends the session's own model when
38
+ * `retry.modelFallback` is on. Measured on this machine — six auto-title
39
+ * attempts across four fresh sessions, every one on
40
+ * `kenari/deepseek-v4-flash`, every one answered `429 plan_limit_reached`
41
+ * after ~52 s, and not a single session named; the two renames that DID succeed
42
+ * (the manual action) ran on `kenari-jatis/deepseek-v4-1-flash`, the
43
+ * conversation's own model, through the pin. `withTitleModelOverride` now wraps
44
+ * BOTH paths, and it is held until the request's frames arrive — omp resolves
45
+ * the role again for the card request that follows a successful title, so a pin
46
+ * released on the ack would produce an uncarded title (or none).
47
+ *
48
+ * ## One attempt per conversation, retried ONCE after the first run
49
+ *
50
+ * The title is derived from the FIRST run and never re-asked after it. omp's
51
+ * `/rename` derives its title from the newest turns (and `renameTitle` reserves
52
+ * a fresh title revision, which cancels any generation still in flight), so a
53
+ * third attempt after turn two would name the session after whatever the
54
+ * conversation had become by then.
40
55
  *
41
56
  * There are exactly two triggers, both inside that first run:
42
57
  *
@@ -49,6 +64,14 @@
49
64
  * no title — a provider error, a timeout, a child that went away, or the
50
65
  * tiny model declining.
51
66
  *
67
+ * **The fallback ALWAYS dispatches.** It used to stand down while the early
68
+ * generation was "still in flight" (the claim window), which assumed that
69
+ * generation would land; on a real session it was the attempt that had already
70
+ * failed 52 s earlier, and the run ended with the window still armed — so the
71
+ * session was never asked again. The two dispatches are serialized
72
+ * (`title-request.server.ts`), and the last one wins omp's title revision,
73
+ * which is the behaviour we want: the fallback reads the completed first turn.
74
+ *
52
75
  * The eligibility latch `autoTitlePending` is consumed by the fallback, so that
53
76
  * is the last chance this conversation gets and a later run can never title it.
54
77
  * `autoTitleRequested` is what keeps the early trigger from firing again on a
@@ -64,7 +87,25 @@
64
87
 
65
88
  import { readSettingsJson } from '@/server/lib/db/settings-store';
66
89
  import { getDb } from '@/server/db.server';
67
- import { GET_STATE_TIMEOUT_MS, PROMPT_ACK_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
90
+ import { GET_STATE_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
91
+ import {
92
+ GENERATE_TITLE_COMMAND,
93
+ sendTitleRequest,
94
+ waitForTitleOutcome,
95
+ type TitleRequestHost,
96
+ } from '@/server/lib/omp/session/title-request.server';
97
+ import { withTitleModelOverride } from '@/server/lib/omp/session/title-model-overlay.server';
98
+
99
+ export {
100
+ GENERATE_TITLE_COMMAND,
101
+ awaitTitleDispatch,
102
+ consumeAutoTitleOutput,
103
+ markTitleRequestSent,
104
+ releaseTitleClaim,
105
+ sendTitleRequest,
106
+ waitForTitleOutcome,
107
+ } from '@/server/lib/omp/session/title-request.server';
108
+ export type { TitleRequestHost } from '@/server/lib/omp/session/title-request.server';
68
109
 
69
110
  /** Settings blob key; mirrors the other blob keys (see `shared/lib/settings/keys`). */
70
111
  const SETTINGS_BLOB_KEY = 'kabmo_settings';
@@ -74,32 +115,10 @@ const SETTINGS_BLOB_KEY = 'kabmo_settings';
74
115
  * and the cost is one tiny-model call per session. */
75
116
  const AUTO_SESSION_TITLE_DEFAULT = true;
76
117
 
77
- /** omp's builtin that generates a title from the conversation. Sent with no
78
- * args so omp derives it through its own `generateRenameTitle`. */
79
- const GENERATE_TITLE_COMMAND = '/rename';
80
-
81
- /**
82
- * How long Kabmo's own `/rename` may keep emitting its diagnostic frames.
83
- *
84
- * The command answers on the prompt path with an immediate ack and then streams
85
- * `session_info_update` + `command_output` once the tiny model returns
86
- * (measured ~4s). Both carry NO correlation id, so the only way to attribute
87
- * them to our background request is this window. It is deliberately generous —
88
- * a slow provider must not leak the message — because the cost of over-holding
89
- * it is only that a slash command typed in the same few seconds loses its
90
- * output row.
91
- */
92
- const AUTO_TITLE_OUTPUT_WINDOW_MS = 60_000;
93
-
94
- /** Wrapper state this module reads and writes. The in-flight flag, the
95
- * output window and the one-shot latch are owned by the caller because they
96
- * are per-session runtime state, not policy. */
97
- export interface AutoTitleHost {
98
- sessionId: string;
99
- autoTitleInFlight: boolean;
100
- /** Epoch ms until which frames belong to our own background rename; 0 when
101
- * no request is outstanding. */
102
- autoTitleWindowUntil: number;
118
+ /** Wrapper state this module reads and writes. The dispatch chain and the claim
119
+ * count are owned by the caller because they are per-session runtime state,
120
+ * not policy. */
121
+ export interface AutoTitleHost extends TitleRequestHost {
103
122
  /** True only while this conversation is still eligible for a title at all.
104
123
  * Seeded from omp's own message count when the child is spawned, so it
105
124
  * survives an idle reclaim (a `--resume` child reports the messages it
@@ -109,42 +128,10 @@ export interface AutoTitleHost {
109
128
  /** True once the early attempt (the first settled user message) has been
110
129
  * made, so a queued steer message cannot re-ask inside the same run. */
111
130
  autoTitleRequested: boolean;
112
- proc: {
113
- sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
114
- };
115
- }
116
-
117
- /**
118
- * Claim the frames our own `/rename` is about to emit, so the wrapper can keep
119
- * its diagnostics out of the timeline — and, more importantly, so the
120
- * `prompt_result {agentInvoked:false}` this background command answers with is
121
- * not read as "the operator's prompt opened no turn".
122
- *
123
- * Called BEFORE the request is sent, never after its ack. omp answers the ack
124
- * and emits the `prompt_result` in the same tick (measured: 3 ms apart on
125
- * 18.7.0), while the operator's turn is still streaming — so a window opened
126
- * after the await is already too late. The `prompt_result` then reached the
127
- * client, which folds it as "the run settled": the generating indicator blanked
128
- * and the sidebar's live `stream` row was cleared while the answer kept
129
- * arriving. `frame-fold` only suppresses the frame when the window is armed, so
130
- * arming late is exactly the hole this ordering closes.
131
- *
132
- * On a send that never reached omp the claim is dropped (see the caller), so a
133
- * window can never belong to nothing and block the settle-stage fallback.
134
- */
135
- export function markTitleRequestSent(host: AutoTitleHost): void {
136
- host.autoTitleWindowUntil = Date.now() + AUTO_TITLE_OUTPUT_WINDOW_MS;
137
- }
138
-
139
- /**
140
- * True when this frame is the tail of our own background rename, consuming the
141
- * window. One frame per request: omp emits exactly one `command_output` for it,
142
- * on both the success and the "could not generate" path.
143
- */
144
- export function consumeAutoTitleOutput(host: AutoTitleHost): boolean {
145
- if (host.autoTitleWindowUntil === 0 || Date.now() > host.autoTitleWindowUntil) return false;
146
- host.autoTitleWindowUntil = 0;
147
- return true;
131
+ /** The model the session is actually served by — the pin the title request
132
+ * runs under. Null before the child reports one; the request then runs on
133
+ * omp's configured role, which is the only honest fallback. */
134
+ autoTitleModel?: { provider: string; modelId: string } | null;
148
135
  }
149
136
 
150
137
  /**
@@ -176,75 +163,118 @@ export type AutoTitleStage = 'opening' | 'settle';
176
163
  /**
177
164
  * Ask omp to name this session from its opening turns.
178
165
  *
179
- * Called twice at most, both inside the conversation's first run: once when the
180
- * first user message settles (`opening`), and once at the terminal `agent_end`
181
- * as a fallback (`settle`). A second run never gets either — see the module
182
- * doc for why a title derived from a later turn is the bug this gate prevents.
183
- *
184
- * Every bail-out is deliberate: a named session is never renamed by this path
185
- * (the name is re-read from omp, so a rename made in another tab is respected),
186
- * a generation already in flight is never duplicated, and failures are
187
- * swallowed — an auto-title is a convenience, and a session whose opening
188
- * message was low-signal ("hi") simply keeps its placeholder.
166
+ * The eligibility gates run SYNCHRONOUSLY, and the request is chained onto the
167
+ * host's dispatch slot before the first await. That ordering is what lets a
168
+ * prompt sent right after the run end wait for this rename to reach the child
169
+ * (`awaitTitleDispatch` in the prompt path) instead of racing it.
189
170
  */
190
- export async function triggerAutoSessionTitle(host: AutoTitleHost, stage: AutoTitleStage): Promise<void> {
191
- if (!host.autoTitlePending) return;
171
+ export function triggerAutoSessionTitle(host: AutoTitleHost, stage: AutoTitleStage): Promise<void> {
172
+ if (!host.autoTitlePending) return Promise.resolve();
192
173
  if (stage === 'opening') {
193
174
  // One early attempt per run. A queued steer message also settles as a user
194
175
  // message inside the same run, and re-asking on it would cancel the first
195
176
  // generation for no gain — the context is the same turn either way.
196
- if (host.autoTitleRequested) return;
197
- // Consumed before the first await: two user messages settling in the same
198
- // tick must not both pass the gate.
177
+ if (host.autoTitleRequested) return Promise.resolve();
178
+ // Consumed synchronously: two user messages settling in the same tick must
179
+ // not both pass the gate.
199
180
  host.autoTitleRequested = true;
200
181
  } else {
201
182
  // Consumed before the first await: the fallback is this conversation's last
202
183
  // chance, and two settles must never both pass the gate.
203
184
  host.autoTitlePending = false;
204
- // The early attempt is still generating. Its `command_output` has not
205
- // arrived, so omp is mid-generation and a second `/rename` now would
206
- // reserve a new title revision and cancel it — same title context, wasted
207
- // model call. Leave the early attempt to finish.
208
- if (host.autoTitleWindowUntil > Date.now()) return;
209
185
  }
210
- if (host.autoTitleInFlight || !host.sessionId) return;
186
+ if (!host.sessionId) return Promise.resolve();
187
+
188
+ // Captured before this run claims the slot: the run's own dispatch queues
189
+ // behind what was already in flight, not behind itself.
190
+ const previous = host.autoTitleDispatch;
191
+ // Two different promises, deliberately:
192
+ // - the DISPATCH resolves when the request is on the wire (or the gate
193
+ // decided not to send). That is what a following prompt waits for, and
194
+ // what the next request is chained behind;
195
+ // - the returned REQUEST resolves when the model pin has been released,
196
+ // which is after the generation answered — a title model can take a
197
+ // minute, and no prompt should wait for that.
198
+ const dispatched = Promise.withResolvers<void>();
199
+ const run = runTitleRequest(host, previous, dispatched.resolve);
200
+ const barrier = dispatched.promise;
201
+ host.autoTitleDispatch = barrier;
202
+ void barrier.then(() => {
203
+ if (host.autoTitleDispatch === barrier) host.autoTitleDispatch = null;
204
+ });
205
+ return run.then(
206
+ () => undefined,
207
+ () => undefined,
208
+ );
209
+ }
210
+
211
+ /** The gated part: settings, the unnamed check, the model pin, the send. */
212
+ async function runTitleRequest(
213
+ host: AutoTitleHost,
214
+ after: Promise<void> | null,
215
+ onDispatched: () => void,
216
+ ): Promise<void> {
217
+ let released = false;
218
+ const release = () => {
219
+ if (released) return;
220
+ released = true;
221
+ onDispatched();
222
+ };
223
+ try {
224
+ await dispatchTitleRequest(host, after, release);
225
+ } finally {
226
+ // Every exit — a disabled setting, a named session, a failed send — must
227
+ // release the barrier: a prompt waiting on it pays the ceiling otherwise.
228
+ release();
229
+ }
230
+ }
231
+
232
+ async function dispatchTitleRequest(
233
+ host: AutoTitleHost,
234
+ after: Promise<void> | null,
235
+ onDispatched: () => void,
236
+ ): Promise<void> {
211
237
  if (!(await isAutoTitleEnabled())) return;
212
238
 
213
- host.autoTitleInFlight = true;
239
+ let state: RpcSessionState;
214
240
  try {
215
241
  // Re-read the name from omp rather than trusting a cached value: a rename
216
242
  // made in another tab (or by omp's own TUI on the same session file) would
217
243
  // otherwise be overwritten by the `"user"` write `/rename` performs. This is
218
244
  // also what makes the fallback a no-op when the early attempt already
219
245
  // landed — the name is simply there by then.
220
- const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
221
- if (state.sessionName?.trim()) return;
222
- // The session changed under us (a reset, or a `switch_session`): the name
223
- // we just read belongs to a different conversation.
224
- if (state.sessionId !== host.sessionId) return;
225
-
226
- // Direct `proc` write, NOT Kabmo's prompt dispatch: this is
227
- // background work, so it must not mark the session streaming or claim the
228
- // sidebar's run row. omp's ack is immediate (`agentInvoked:false`); the
229
- // generation itself streams back later as ordinary frames.
230
- //
231
- // The claim is armed BEFORE the send, not after its ack: omp emits the
232
- // `prompt_result {agentInvoked:false}` that answers this command within
233
- // milliseconds, and the fold can only suppress it while the window is open
234
- // (see `markTitleRequestSent`). An armed window is rolled back if the send
235
- // never reached omp, so it cannot outlive a request that did not happen.
236
- markTitleRequestSent(host);
237
- try {
238
- await host.proc.sendCommand({ type: 'prompt', message: GENERATE_TITLE_COMMAND }, PROMPT_ACK_TIMEOUT_MS);
239
- } catch (error) {
240
- host.autoTitleWindowUntil = 0;
241
- throw error;
242
- }
246
+ state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
243
247
  } catch {
244
248
  // Provider error, timeout, or a child that went away — leave the session
245
- // unnamed; the settle attempt (or the next run, for an aborted turn) tries
246
- // again.
247
- } finally {
248
- host.autoTitleInFlight = false;
249
+ // unnamed rather than naming the wrong conversation.
250
+ return;
249
251
  }
252
+ if (state.sessionName?.trim()) return;
253
+ // The session changed under us (a reset, or a `switch_session`): the name we
254
+ // just read belongs to a different conversation.
255
+ if (state.sessionId !== host.sessionId) return;
256
+
257
+ const model = host.autoTitleModel;
258
+ const selector = model ? `${model.provider}/${model.modelId}` : undefined;
259
+ await withTitleModelOverride(selector, async () => {
260
+ try {
261
+ // Direct `proc` write, NOT Kabmo's prompt dispatch: this is background
262
+ // work, so it must not mark the session streaming or claim the sidebar's
263
+ // run row. omp's ack is immediate (`agentInvoked:false`); the generation
264
+ // itself streams back later as ordinary frames.
265
+ await sendTitleRequest(host, GENERATE_TITLE_COMMAND, after);
266
+ } finally {
267
+ // The request is on the wire (or refused): a prompt waiting on this
268
+ // rename must be released now, not after the generation.
269
+ onDispatched();
270
+ }
271
+ // The pin is held across the generation, not just the dispatch: omp
272
+ // resolves the title role again for the card request that follows a
273
+ // successful title, and releasing it there names the session with the
274
+ // configured role — the failure the pin exists to avoid.
275
+ await waitForTitleOutcome(host);
276
+ }).catch(() => {
277
+ // A refused send or a cancelled generation leaves the session unnamed; the
278
+ // other stage (or nothing) is the honest outcome.
279
+ });
250
280
  }
@@ -35,7 +35,9 @@ interface FakeProc {
35
35
  sent: Promise<void>;
36
36
  }
37
37
 
38
- function makeHost(options: { sendFails?: boolean } = {}): { host: TitleFrameHost; proc: FakeProc } {
38
+ function makeHost(
39
+ options: { sendFails?: boolean; onSend?: (host: TitleFrameHost) => void } = {},
40
+ ): { host: TitleFrameHost; proc: FakeProc } {
39
41
  const listeners = new Set<(frame: Frame) => void>();
40
42
  const sent = Promise.withResolvers<void>();
41
43
  const proc: FakeProc = {
@@ -46,6 +48,7 @@ function makeHost(options: { sendFails?: boolean } = {}): { host: TitleFrameHost
46
48
  },
47
49
  async sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T> {
48
50
  proc.commands.push(command);
51
+ options.onSend?.(host);
49
52
  sent.resolve();
50
53
  if (options.sendFails) throw new Error('omp RPC process has exited');
51
54
  // omp acks /rename before it has a title — the case that makes waiting
@@ -56,10 +59,12 @@ function makeHost(options: { sendFails?: boolean } = {}): { host: TitleFrameHost
56
59
 
57
60
  const host = {
58
61
  sessionId: 'sess-1',
59
- autoTitleInFlight: false,
60
62
  autoTitleWindowUntil: 0,
63
+ autoTitleClaims: 0,
64
+ autoTitleDispatch: null,
61
65
  autoTitlePending: false,
62
66
  autoTitleRequested: false,
67
+ autoTitleModel: null,
63
68
  proc: {
64
69
  sendCommand: proc.sendCommand,
65
70
  onFrame(listener: (frame: Frame) => void) {
@@ -117,14 +122,21 @@ describe('renameSessionWithAi frame handling', () => {
117
122
  });
118
123
 
119
124
  test('the request claims the output window before sending, so the frames stay out of the timeline', async () => {
120
- const { host, proc } = makeHost();
125
+ let claimsAtSend = -1;
126
+ const { host, proc } = makeHost({
127
+ onSend: (h) => {
128
+ claimsAtSend = h.autoTitleClaims;
129
+ },
130
+ });
121
131
  const pending = awaitTitleFrame(host);
122
132
 
123
- // Claimed synchronously with the call, not on the ack — a title frame that
124
- // overtakes the ack must still be attributed to this request.
133
+ // Armed before the `prompt` reaches the wire, not on its ack: omp answers
134
+ // within the same tick (measured ~3 ms), so a claim armed after the ack can
135
+ // already be too late for the frame it was meant to cover.
136
+ await proc.sent;
137
+ expect(claimsAtSend).toBe(1);
125
138
  expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
126
139
 
127
- await proc.sent;
128
140
  proc.emit({ type: 'session_info_update', title: 'Named' });
129
141
  await pending;
130
142
  });
@@ -36,9 +36,13 @@
36
36
  */
37
37
 
38
38
  import { getRpcSession, resolveSpawnCwd, startRpcSession } from '@/server/lib/omp/rpc/manager';
39
- import { PROMPT_ACK_TIMEOUT_MS } from '@/server/lib/omp/rpc/constants';
40
39
  import { resolveSessionPathOr404 } from '@/server/lib/omp/session/locator';
41
- import { markTitleRequestSent, type AutoTitleHost } from '@/server/lib/omp/session/auto-title.server';
40
+ import type { AutoTitleHost } from '@/server/lib/omp/session/auto-title.server';
41
+ import {
42
+ GENERATE_TITLE_COMMAND,
43
+ releaseTitleClaim,
44
+ sendTitleRequest,
45
+ } from '@/server/lib/omp/session/title-request.server';
42
46
  import { resolveSessionOwnership, SessionOwnedElsewhereError } from '@/server/lib/omp/session/ownership.server';
43
47
  import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
44
48
  import { loadSessionModel } from '@/server/lib/omp/session/messages';
@@ -169,29 +173,41 @@ export function awaitTitleFrame(host: TitleFrameHost): Promise<RenameWithAiResul
169
173
  });
170
174
 
171
175
  const timer = setTimeout(() => {
176
+ // A request that never answered can never be answered: drop its claim, or
177
+ // it would swallow the operator's next `command_output` as "our rename".
178
+ releaseTitleClaim(host);
172
179
  finish({ ok: false, error: 'Timed out waiting for a generated title.', status: 504 });
173
180
  }, GENERATE_TIMEOUT_MS);
174
181
  // A pending generation must never hold the event loop open on its own.
175
182
  timer.unref?.();
176
183
 
177
- // The window is claimed BEFORE the send, not after its ack: omp's response to
178
- // the prompt and the rename's own frames are separate frames, and a fast
179
- // child could deliver the title first — in which case a window marked on the
180
- // ack would let that one frame through to the timeline. A send that never
181
- // reached omp clears it again, so nothing is left claimed.
182
- markTitleRequestSent(host);
183
- void (async () => {
184
- try {
185
- await host.proc.sendCommand({ type: 'prompt', message: '/rename' }, PROMPT_ACK_TIMEOUT_MS);
186
- } catch (error) {
187
- host.autoTitleWindowUntil = 0;
188
- finish({
189
- ok: false,
190
- error: error instanceof Error ? error.message : String(error),
191
- status: 502,
192
- });
193
- }
194
- })();
184
+ // The claim is armed by the shared dispatcher BEFORE the send (and released
185
+ // by it if the send never reached omp), so the `command_output` this request
186
+ // answers with stays out of the timeline. The claim is deliberately NOT
187
+ // released on the success frame: omp emits `session_info_update` first and
188
+ // the `command_output` a few milliseconds later, and releasing on the title
189
+ // would let that second frame render as a notice row.
190
+ //
191
+ // The dispatch slot is claimed SYNCHRONOUSLY, before the first await, so a
192
+ // prompt the operator sends while this rename is still generating waits for
193
+ // it instead of racing it (`awaitTitleDispatch` in the prompt path).
194
+ const previous = host.autoTitleDispatch ?? null;
195
+ const request = sendTitleRequest(host, GENERATE_TITLE_COMMAND, previous);
196
+ const dispatched = request.then(
197
+ () => undefined,
198
+ () => undefined,
199
+ );
200
+ host.autoTitleDispatch = dispatched;
201
+ void dispatched.then(() => {
202
+ if (host.autoTitleDispatch === dispatched) host.autoTitleDispatch = null;
203
+ });
204
+ void request.catch((error: unknown) => {
205
+ finish({
206
+ ok: false,
207
+ error: error instanceof Error ? error.message : String(error),
208
+ status: 502,
209
+ });
210
+ });
195
211
 
196
212
  return promise;
197
213
  }
@@ -128,25 +128,40 @@ describe('title-model overlay', () => {
128
128
  expect(readFileSync(path, 'utf8')).toBe('{}\n');
129
129
  });
130
130
 
131
- test('concurrent overrides are serialized, so one cannot reset the other mid-flight', async () => {
131
+ test('concurrent overrides are newest-wins, and the last holder releases the pin', async () => {
132
+ // The overlay is ONE file for every session, and a title model can take a
133
+ // minute — queueing a second request behind the first would make the
134
+ // operator's next prompt wait on a generation it has nothing to do with.
135
+ // So the most recent request owns the file, and the file is cleared only
136
+ // when no request holds it.
132
137
  const { withTitleModelOverride, ensureTitleModelOverlay, titleModelOverlayPath } = await overlay();
133
138
  ensureTitleModelOverlay();
134
139
  const path = titleModelOverlayPath();
135
- const order: string[] = [];
140
+ const seen: string[] = [];
141
+ const releaseFirst = Promise.withResolvers<void>();
142
+ const firstHolds = Promise.withResolvers<void>();
143
+
144
+ const first = withTitleModelOverride('first/model', async () => {
145
+ expect(readFileSync(path, 'utf8')).toContain('first/model');
146
+ seen.push('first');
147
+ firstHolds.resolve();
148
+ // Still generating when the second request arrives.
149
+ await releaseFirst.promise;
150
+ });
151
+ await firstHolds.promise;
136
152
 
137
- await Promise.all([
138
- withTitleModelOverride('first/model', async () => {
139
- expect(readFileSync(path, 'utf8')).toContain('first/model');
140
- order.push('first');
141
- }),
142
- withTitleModelOverride('second/model', async () => {
143
- // The first has already reset by the time this one runs.
144
- expect(readFileSync(path, 'utf8')).toContain('second/model');
145
- order.push('second');
146
- }),
147
- ]);
153
+ const second = withTitleModelOverride('second/model', async () => {
154
+ // The newer request owns the file even though the older one is running.
155
+ expect(readFileSync(path, 'utf8')).toContain('second/model');
156
+ seen.push('second');
157
+ });
158
+ await second;
159
+
160
+ // The older holder leaving must NOT clear the newer pin.
161
+ releaseFirst.resolve();
162
+ await first;
148
163
 
149
- expect(order).toEqual(['first', 'second']);
164
+ expect(seen).toEqual(['first', 'second']);
150
165
  expect(readFileSync(path, 'utf8')).toBe('{}\n');
151
166
  });
152
167
  });
@@ -120,42 +120,63 @@ async function writeOverlay(selector: string | undefined): Promise<void> {
120
120
  }
121
121
 
122
122
  /**
123
- * Serializes the write → settle → run → reset sequence.
123
+ * Holders of the pin, and the selector each asked for.
124
124
  *
125
- * The overlay is ONE file for every session, so two concurrent renames would
126
- * otherwise interleave: the second's write would land inside the first's
127
- * generation and reset the role out from under it. Renames are user-initiated
128
- * and rare, so queueing them costs nothing a user can perceive.
125
+ * The overlay is ONE file for every session, so a pin cannot be "owned" by one
126
+ * request while another waits: a title model can take a minute, and queueing
127
+ * the second request behind the first would make the operator's next prompt
128
+ * wait on a generation it has nothing to do with. Instead the pin is
129
+ * NEWEST-WINS — every holder is recorded, the file always carries the most
130
+ * recent selector, and the file is cleared only when the last holder leaves.
131
+ * A request that is superseded mid-generation is one omp cancels anyway
132
+ * (`renameTitle` reserves a title revision), so it loses nothing it could have
133
+ * kept.
129
134
  */
130
- let queue: Promise<unknown> = Promise.resolve();
135
+ interface PinHolder {
136
+ selector: string;
137
+ order: number;
138
+ }
139
+ const holders = new Set<PinHolder>();
140
+ let nextOrder = 1;
141
+ /** Serializes the FILE writes; a write is one atomic replace, never held. */
142
+ let writeQueue: Promise<unknown> = Promise.resolve();
131
143
 
132
- const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
144
+ /** Write the selector the current holders agree on (newest wins), or clear. */
145
+ function syncOverlay(): Promise<void> {
146
+ const newest = [...holders].sort((a, b) => b.order - a.order)[0];
147
+ const selector = newest?.selector;
148
+ const run = writeQueue.then(() => writeOverlay(selector));
149
+ writeQueue = run.then(
150
+ () => undefined,
151
+ () => undefined,
152
+ );
153
+ return run;
154
+ }
133
155
 
134
156
  /**
135
- * Run `fn` with the title role pointed at `selector`, then restore the overlay
136
- * to its empty state.
157
+ * Run `fn` with the title role pointed at `selector`, and release the pin when
158
+ * `fn` settles.
137
159
  *
138
160
  * `selector` of `undefined` (a session whose model cannot be read) runs `fn`
139
161
  * untouched: naming a session with a model the conversation never used is worse
140
- * than the role the user configured. The reset is in a `finally`, so a refused
162
+ * than the role the user configured. The release is in a `finally`, so a refused
141
163
  * or failed rename cannot leave the role pinned for the next child that loads
142
164
  * the overlay.
165
+ *
166
+ * `fn` should resolve when the generation is DONE, not when the request is
167
+ * dispatched: omp resolves the title role again for the card request that
168
+ * follows a successful title, so releasing on the ack names the session with
169
+ * the configured role — the failure this pin exists to prevent.
143
170
  */
144
171
  export function withTitleModelOverride<T>(selector: string | undefined, fn: () => Promise<T>): Promise<T> {
145
172
  if (!selector) return fn();
146
- const run = queue.then(async () => {
147
- await writeOverlay(selector);
148
- await wait(OVERLAY_SETTLE_MS);
149
- try {
150
- return await fn();
151
- } finally {
152
- await writeOverlay(undefined);
153
- }
154
- });
155
- // Keep the chain alive on failure without swallowing the caller's rejection.
156
- queue = run.then(
157
- () => undefined,
158
- () => undefined,
159
- );
160
- return run;
173
+ const holder: PinHolder = { selector, order: nextOrder++ };
174
+ holders.add(holder);
175
+ return syncOverlay()
176
+ .then(() => Bun.sleep(OVERLAY_SETTLE_MS))
177
+ .then(fn)
178
+ .finally(async () => {
179
+ holders.delete(holder);
180
+ await syncOverlay();
181
+ });
161
182
  }