@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-ae9z0yvm.js} +704 -704
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +5 -8
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- 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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
|
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
|
|
198
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
124
|
-
//
|
|
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 {
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
-
*
|
|
123
|
+
* Holders of the pin, and the selector each asked for.
|
|
124
124
|
*
|
|
125
|
-
* The overlay is ONE file for every session, so
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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`,
|
|
136
|
-
*
|
|
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
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
}
|