@kabmo-labs/kabmo 0.5.2 → 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 (124) 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-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
  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 +10 -15
  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 -6
  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/chat/omp/session-list.test.ts +0 -24
  44. package/src/client/hooks/desktop/servers.test.ts +73 -0
  45. package/src/client/hooks/desktop/servers.ts +126 -0
  46. package/src/client/hooks/settings/model-roles.ts +156 -0
  47. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  48. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  49. package/src/client/hooks/workspace/keybindings.ts +44 -0
  50. package/src/client/hooks/workspace/open-panel.ts +42 -0
  51. package/src/client/lib/desktop/bridge.ts +158 -0
  52. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  53. package/src/client/lib/desktop/titlebar.ts +97 -0
  54. package/src/client/lib/plugins/slots.test.ts +54 -0
  55. package/src/client/lib/plugins/slots.ts +37 -1
  56. package/src/server/db.server.test.ts +44 -3
  57. package/src/server/db.server.ts +26 -2
  58. package/src/server/lib/bundler/build-errors.test.ts +0 -6
  59. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  60. package/src/server/lib/db/schema.ts +3 -0
  61. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  62. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  63. package/src/server/lib/model-roles/mock.server.ts +40 -0
  64. package/src/server/lib/model-roles/schema.server.ts +36 -0
  65. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  66. package/src/server/lib/model-roles/store.server.ts +188 -0
  67. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  68. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  69. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  70. package/src/server/lib/omp/rpc/manager.ts +18 -13
  71. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  72. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  73. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  74. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  75. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  76. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  77. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  78. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  79. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  80. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  81. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  82. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  83. package/src/server/lib/panels/compat.server.test.ts +4 -4
  84. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  85. package/src/server/lib/panels/registry.server.test.ts +4 -4
  86. package/src/server/lib/panels/state.server.test.ts +4 -4
  87. package/src/server/lib/panels/sync.server.test.ts +4 -4
  88. package/src/server/lib/plugins/background.test.ts +2 -2
  89. package/src/server/lib/plugins/e2e.test.ts +2 -2
  90. package/src/server/lib/plugins/platform.test.ts +2 -2
  91. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  92. package/src/server/lib/queue/delivery.server.ts +11 -2
  93. package/src/server/lib/updates/shell-release.test.ts +184 -0
  94. package/src/server/lib/updates/shell-release.ts +215 -0
  95. package/src/server/lib/waiting/store.server.test.ts +2 -2
  96. package/src/server/plugins/ssr.ts +13 -10
  97. package/src/server/routes/chat/stream.test.ts +4 -4
  98. package/src/server/routes/fs/git-merge.test.ts +0 -11
  99. package/src/server/routes/models/roles.ts +73 -14
  100. package/src/server/routes/panels/css.test.ts +4 -4
  101. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  102. package/src/server/routes/panels/routes.test.ts +0 -7
  103. package/src/server/routes/plugins/routes.test.ts +4 -4
  104. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  105. package/src/server/routes/updates/android.ts +2 -2
  106. package/src/server/routes/updates/index.ts +2 -0
  107. package/src/server/routes/updates/ios.ts +26 -0
  108. package/src/server/routes/waiting/routes.test.ts +2 -2
  109. package/src/shared/lib/models/server-cache.ts +11 -0
  110. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  111. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  112. package/src/shared/lib/updates/shell-ua.ts +41 -20
  113. package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
  114. package/src/shared/lib/workspace/mobile-dock.ts +15 -6
  115. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  116. package/src/shared/lib/workspace/panel-open.ts +76 -0
  117. package/src/shared/types/model-roles.ts +54 -0
  118. package/src/test-support/isolated-db.ts +17 -5
  119. package/dist/client/static/js/index-3qamd33d.css +0 -1
  120. package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
  121. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  122. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  123. package/src/server/lib/updates/android-release.test.ts +0 -138
  124. package/src/server/lib/updates/android-release.ts +0 -183
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * CRUD for model-role presets (`model_role_presets`).
8
+ *
9
+ * A preset is Kabmo's record of a `modelRoles` map under a name, so the user can
10
+ * switch between whole role assignments instead of retyping every row. Applying
11
+ * one is NOT this module's job: the route writes the map through
12
+ * `writeModelRoles`, the same writer the individual role rows use, so a preset
13
+ * can never produce a config shape the row editor could not.
14
+ *
15
+ * Two invariants:
16
+ * - `name` is the identity a user sees, and it is unique case-insensitively
17
+ * (the column is `COLLATE NOCASE UNIQUE`), so a rename onto an existing
18
+ * name is refused rather than silently creating a twin.
19
+ * - A role id is NOT narrowed to omp's built-ins. `modelRoles` accepts custom
20
+ * role ids (omp adds any configured role to its own role list), and dropping
21
+ * them here would delete a custom role the user had assigned the moment a
22
+ * preset was applied — the whole map is written back, so a filtered copy is
23
+ * a silent edit of the live config.
24
+ */
25
+
26
+ import { getDb } from '@/server/db.server';
27
+ import { readSettingsJson, writeSettingsJson } from '@/server/lib/db/settings-store';
28
+ import { isMockMode } from '@/server/mock.server';
29
+ import { normalizeRoleMap } from '@/shared/lib/omp/config/model-roles';
30
+ import type { ModelRolePreset } from '@/shared/types/model-roles';
31
+
32
+ interface PresetRow {
33
+ id: string;
34
+ name: string;
35
+ roles: string;
36
+ created_at: number;
37
+ updated_at: number;
38
+ }
39
+
40
+ /** Names a preset may carry: no control characters, no leading/trailing space. */
41
+ const NAME_MAX = 60;
42
+
43
+ function parseRoles(raw: string): Record<string, string> {
44
+ try {
45
+ return normalizeRoleMap(JSON.parse(raw));
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ function rowToPreset(row: PresetRow): ModelRolePreset {
52
+ return {
53
+ id: row.id,
54
+ name: row.name,
55
+ roles: parseRoles(row.roles),
56
+ createdAt: row.created_at,
57
+ updatedAt: row.updated_at,
58
+ };
59
+ }
60
+
61
+ /** Trim + validate a preset name; throws with a sentence the form can show. */
62
+ export function normalizePresetName(raw: unknown): string {
63
+ if (typeof raw !== 'string') throw new Error('Preset name is required');
64
+ const name = raw.trim();
65
+ if (!name) throw new Error('Preset name is required');
66
+ if (name.length > NAME_MAX) throw new Error(`Preset name is limited to ${NAME_MAX} characters`);
67
+ if (/[\u0000-\u001f]/.test(name)) throw new Error('Preset name contains control characters');
68
+ return name;
69
+ }
70
+
71
+ export async function listModelRolePresets(): Promise<ModelRolePreset[]> {
72
+ const db = await getDb();
73
+ const rows = (await db.all('SELECT * FROM model_role_presets ORDER BY name COLLATE NOCASE ASC')) as PresetRow[];
74
+ return rows.map(rowToPreset);
75
+ }
76
+
77
+ export async function getModelRolePreset(id: string): Promise<ModelRolePreset | null> {
78
+ const db = await getDb();
79
+ const row = (await db.get('SELECT * FROM model_role_presets WHERE id = ?', [id])) as PresetRow | undefined;
80
+ return row ? rowToPreset(row) : null;
81
+ }
82
+
83
+ /**
84
+ * Create or update a preset. `id: null` creates; an id updates that row and
85
+ * keeps its `created_at`. Returns the stored preset.
86
+ *
87
+ * A duplicate name is caught by the unique index, and the raw SQLite sentence
88
+ * ("UNIQUE constraint failed: …") is replaced with the sentence a user can act
89
+ * on — the route surfaces this text verbatim.
90
+ */
91
+ export async function saveModelRolePreset(input: {
92
+ id: string | null;
93
+ name: unknown;
94
+ roles: unknown;
95
+ }): Promise<ModelRolePreset> {
96
+ const name = normalizePresetName(input.name);
97
+ const roles = normalizeRoleMap(input.roles);
98
+ if (Object.keys(roles).length === 0) throw new Error('A preset needs at least one role assigned');
99
+
100
+ const db = await getDb();
101
+ const now = Date.now();
102
+ if (input.id) {
103
+ const existing = await getModelRolePreset(input.id);
104
+ if (!existing) throw new Error('Preset not found');
105
+ try {
106
+ await db.run(
107
+ 'UPDATE model_role_presets SET name = ?, roles = ?, updated_at = ? WHERE id = ?',
108
+ [name, JSON.stringify(roles), now, input.id],
109
+ );
110
+ } catch (error) {
111
+ throw new Error(duplicateNameMessage(error, name));
112
+ }
113
+ const stored = await getModelRolePreset(input.id);
114
+ if (!stored) throw new Error('Preset not found');
115
+ return stored;
116
+ }
117
+
118
+ const id = crypto.randomUUID();
119
+ try {
120
+ await db.run(
121
+ 'INSERT INTO model_role_presets (id, name, roles, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
122
+ [id, name, JSON.stringify(roles), now, now],
123
+ );
124
+ } catch (error) {
125
+ throw new Error(duplicateNameMessage(error, name));
126
+ }
127
+ const stored = await getModelRolePreset(id);
128
+ if (!stored) throw new Error('Preset not found after insert');
129
+ return stored;
130
+ }
131
+
132
+ function duplicateNameMessage(error: unknown, name: string): string {
133
+ const text = error instanceof Error ? error.message : String(error);
134
+ return /UNIQUE constraint failed/i.test(text) ? `A preset named “${name}” already exists` : text;
135
+ }
136
+
137
+ /**
138
+ * Name of the preset seeded from the roles an install already had.
139
+ *
140
+ * Named for what it is — the assignment the user was running before presets
141
+ * existed — rather than after a strategy ("cheap", "thinking"), which would be a
142
+ * claim the seeder cannot make.
143
+ */
144
+ export const SEEDED_PRESET_NAME = 'Default';
145
+
146
+ /** `app_settings` marker: the one-shot seed has already made its decision. */
147
+ const SEED_MARKER_KEY = 'model_role_presets_seeded';
148
+
149
+ /**
150
+ * Seed ONE preset from the `modelRoles` map an install already carries, so the
151
+ * picker is never empty on a machine that has been running omp for months.
152
+ *
153
+ * One-shot, and the marker is written on EVERY path that decides — the same
154
+ * shape `workspace_sync_ran` uses, and for the same reason: "the table is empty"
155
+ * has two meanings a row count cannot separate (never seeded, and the user
156
+ * deleted the seeded preset). Without the marker a delete would resurrect it on
157
+ * the next panel open.
158
+ *
159
+ * Three cases step aside and still spend the run:
160
+ * - a map with no roles (a fresh install has nothing to snapshot);
161
+ * - a table that already holds a preset (including one written by hand);
162
+ * - MOCK mode, which must not write demo data into the database.
163
+ *
164
+ * A failure to insert is swallowed: a seed is a convenience, and the panel's own
165
+ * `Save as…` is the manual path. The marker is still written, so a config that
166
+ * omp rejects cannot make every panel open retry it.
167
+ */
168
+ export async function seedPresetFromCurrentRoles(roles: Record<string, string>): Promise<void> {
169
+ if (isMockMode()) return;
170
+ const db = await getDb();
171
+ if (await readSettingsJson<boolean>(db, SEED_MARKER_KEY, false)) return;
172
+ const existing = await db.get<{ id: string }>('SELECT id FROM model_role_presets LIMIT 1');
173
+ if (!existing && Object.keys(roles).length > 0) {
174
+ try {
175
+ await saveModelRolePreset({ id: null, name: SEEDED_PRESET_NAME, roles });
176
+ } catch {
177
+ // A name collision or a bad selector is not worth failing a read over.
178
+ }
179
+ }
180
+ await writeSettingsJson(db, SEED_MARKER_KEY, true);
181
+ }
182
+
183
+ /** Delete a preset; returns false when the id named nothing. */
184
+ export async function deleteModelRolePreset(id: string): Promise<boolean> {
185
+ const db = await getDb();
186
+ const result = await db.run('DELETE FROM model_role_presets WHERE id = ?', [id]);
187
+ return result.changes > 0;
188
+ }
@@ -56,12 +56,12 @@ function makeProc(): RpcProcess {
56
56
  * path at a temp file (the order is the point), and `releaseDb` closes it again
57
57
  * before deleting — the only shape that works on Windows.
58
58
  */
59
- beforeEach(() => {
60
- isolateDb();
59
+ beforeEach(async () => {
60
+ await isolateDb();
61
61
  });
62
62
 
63
- afterEach(() => {
64
- releaseDb();
63
+ afterEach(async () => {
64
+ await releaseDb();
65
65
  });
66
66
 
67
67
  /** A wrapper with a live run and a matching `stream` row behind it. */
@@ -27,8 +27,9 @@ import { ModeMirror } from '@/server/lib/omp/rpc/mode-mirror';
27
27
  function makeHost(overrides: Partial<SessionFrameHost> = {}): SessionFrameHost {
28
28
  return {
29
29
  sessionId: '',
30
- autoTitleInFlight: false,
31
30
  autoTitleWindowUntil: 0,
31
+ autoTitleClaims: 0,
32
+ autoTitleDispatch: null,
32
33
  autoTitlePending: true,
33
34
  autoTitleRequested: false,
34
35
  promptRunning: false,
@@ -298,3 +299,31 @@ describe('releasesStreamRowOnPromptResult', () => {
298
299
  expect(releasesStreamRowOnPromptResult({ agentInvoked: false }, { streaming: false, sessionId: '' })).toBe(false);
299
300
  });
300
301
  });
302
+
303
+ /**
304
+ * A rename writes the title SLOT in place, which no mtime-keyed scan can see.
305
+ *
306
+ * The sidebar therefore has to be republished from this frame, not only from
307
+ * the turn-boundary signals: measured, a session named by its own run kept
308
+ * `New Session - <timestamp>` in the list while the header already showed the
309
+ * title, until an unrelated tool call happened to republish.
310
+ */
311
+ describe('session_info_update', () => {
312
+ test('drops the sidebar scan cache, which the in-place title write cannot invalidate itself', () => {
313
+ // The title is rewritten in a fixed-width slot INSIDE the session file, so
314
+ // the mtime the scan cache keys on does not move. Without this the list
315
+ // kept `New Session - <timestamp>` while the header already showed the
316
+ // title, until an unrelated tool call happened to republish.
317
+ globalThis.__kabmoSidebarDataCache = {
318
+ data: {} as never,
319
+ expiresAt: Date.now() + 60_000,
320
+ inFlight: null,
321
+ };
322
+
323
+ const host = makeHost({ sessionId: 's1' });
324
+ const result = foldSessionFrame(host, { type: 'session_info_update', title: 'A new name' });
325
+
326
+ expect(result.suppressForward).toBe(false);
327
+ expect(globalThis.__kabmoSidebarDataCache?.expiresAt).toBe(0);
328
+ });
329
+ });
@@ -269,8 +269,13 @@ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): Fra
269
269
  case 'session_info_update':
270
270
  // A rename rewrote the fixed-width title slot in place; the scan cache is
271
271
  // keyed on file mtime, which that write cannot move, so the list must be
272
- // rebuilt explicitly.
272
+ // rebuilt explicitly. Published HERE, not only from the `message_end`
273
+ // fallback: the title frame is the last thing a rename produces, and
274
+ // without this the sidebar kept the placeholder until the next turn's
275
+ // tool call republished it — measured as a session that had a name in the
276
+ // header and `New Session - <timestamp>` in the list.
273
277
  clearSessionFileCaches();
278
+ publishSidebarStructure();
274
279
  break;
275
280
  // omp's own goal transitions. The wrapper keeps the record so a snapshot
276
281
  // (`get_state` plus the live frame) can report the mode without a round
@@ -81,24 +81,29 @@ export class AgentSessionWrapper {
81
81
  // Public so `RunSettle` can retire a stranded entry on omp's own quiescence
82
82
  // verdict (see `run-settle.server.ts`).
83
83
  readonly subagents = new SubagentLiveness();
84
- // One background `/rename` at a time. The first run makes up to two attempts
85
- // (the early one at the first user message, the fallback at run end), and two
86
- // overlapping generations would race to write the same title slot.
87
- autoTitleInFlight = false;
84
+ // The `/rename` dispatch chain (see `session/title-request.server.ts`): a
85
+ // prompt after the run end waits here so the rename goes first.
86
+ autoTitleDispatch: Promise<void> | null = null;
88
87
  /** omp's own plan/goal mode, mirrored from the frames. */
89
88
  readonly modeMirror = new ModeMirror();
90
- // One-shot: the FIRST run of this conversation is the only one Kabmo
91
- // may ask omp to name it from. Seeded in applyIdentity from omp's own message
92
- // count, so a session reclaimed and respawned with `--resume` (which reports
93
- // the messages it restored) never re-titles from its newest turn.
89
+ // One-shot: the FIRST run is the only one Kabmo may ask omp to name from.
90
+ // Seeded in applyIdentity from omp's own message count, so a child resumed
91
+ // with `--resume` (which reports the messages it restored) never re-titles.
94
92
  autoTitlePending = true;
95
- // True once this run's early attempt (the first settled user message) has
96
- // been made. Reset at each `agent_start`, so a queued steer message inside the
97
- // same run cannot re-ask and cancel the generation already in flight.
93
+ // True once this run's early attempt was made, so a queued steer cannot
94
+ // re-ask inside the run. Reset at each `agent_start`.
98
95
  autoTitleRequested = false;
99
- // Epoch ms until which `command_output` frames belong to our own background
100
- // rename and must not reach the timeline. 0 when nothing is outstanding.
96
+ // Epoch ms until which `command_output` frames belong to our own rename.
101
97
  autoTitleWindowUntil = 0;
98
+ // Outstanding requests, one claim each: both attempts can be unanswered at
99
+ // once, and one slot let the first one's output reach the timeline.
100
+ autoTitleClaims = 0;
101
+ /** The pin a title request runs under (see `auto-title.server.ts`). A GETTER
102
+ * over `runModel`: `get_state`, the `set_model` ack and the fallback probe all
103
+ * write that one field, and a second copy would pin a model the session left. */
104
+ get autoTitleModel(): SessionRunModel | null {
105
+ return this.runModel;
106
+ }
102
107
  /** Idle reclaim. Its own object because the rules are self-contained: a busy
103
108
  * session is never reclaimed, and every frame defers the deadline. */
104
109
  readonly idle: IdleReaper;
@@ -49,6 +49,10 @@ export interface SessionCommandHost extends WebStateHost {
49
49
  /** Arm the deadline watchdog for a dispatch whose turn never opened. Optional
50
50
  * so a test host can omit it; the real wrapper always implements it. */
51
51
  armAgentStartWatchdog?(): void;
52
+ /** The background `/rename` dispatch a following prompt must be ordered
53
+ * behind (see `session/title-request.server.ts`). Absent on a host that
54
+ * never titles, which is why the barrier treats it as idle. */
55
+ autoTitleDispatch?: Promise<void> | null;
52
56
  destroyAndWait(): Promise<void>;
53
57
  }
54
58
 
@@ -82,6 +82,30 @@ describe('guards before dispatch', () => {
82
82
  });
83
83
 
84
84
  describe('prompt', () => {
85
+ test('a prompt waits for the rename a finished run asked for', async () => {
86
+ // Both are separate frames on one stdin. Without the barrier the prompt can
87
+ // be admitted first, and the rename then names a conversation that has
88
+ // already moved on — measured on a real session as a title derived from the
89
+ // second prompt instead of the first run.
90
+ const { promise: dispatchAck, resolve: ack } = Promise.withResolvers<void>();
91
+ const h = makeHarness({ autoTitleDispatch: dispatchAck });
92
+ h.respond(() => ({ agentInvoked: true }));
93
+
94
+ let admitted = false;
95
+ const prompt = dispatchSessionCommand(h.host, { type: 'prompt', message: 'second' }).then(() => {
96
+ admitted = true;
97
+ });
98
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
99
+ // The rename is still on its way to the child, so the prompt must not have
100
+ // reached it.
101
+ expect(admitted).toBe(false);
102
+ expect(h.calls).toEqual([]);
103
+
104
+ ack();
105
+ await prompt;
106
+ expect(h.calls).toEqual([{ type: 'prompt', message: 'second' }]);
107
+ });
108
+
85
109
  test('the accepted ack arms the agent-start watchdog instead of clearing the turn', async () => {
86
110
  const h = makeHarness();
87
111
  h.respond(() => ({ agentInvoked: true }));
@@ -12,6 +12,7 @@
12
12
  import { RpcCommandError, RpcCommandTimeoutError } from '@/server/lib/omp/rpc/process';
13
13
  import { AGENT_BUSY_MESSAGE, AGENT_BUSY_REFUSAL_RE, AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, STEER_ACK_TIMEOUT_MS, WebRpcError, toImageContents, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
14
14
  import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
15
+ import { awaitTitleDispatch } from '@/server/lib/omp/session/title-request.server';
15
16
  import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
16
17
  import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
17
18
  import { buildWebState } from '@/server/lib/omp/rpc/web-state';
@@ -45,6 +46,12 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
45
46
  // refusal from clearing a running turn's flags when the prompt was a
46
47
  // steer aimed at that turn.
47
48
  if (refuseTuiOnlyPrompt(host, command.message, Boolean(command.streamingBehavior))) return null;
49
+ // A rename the finished run asked for must be ON THE WIRE before this
50
+ // prompt is: both are separate frames on one stdin, and a prompt admitted
51
+ // first lets the rename name a conversation that has already moved on.
52
+ // The wait is the dispatch ack (~3 ms), never the generation, and it is
53
+ // bounded so a wedged child cannot hold the operator's prompt.
54
+ await awaitTitleDispatch(host);
48
55
  const streamingBehavior = command.streamingBehavior as 'steer' | 'followUp' | undefined;
49
56
  // The live `stream` row starts with the DISPATCH, not with agent_start:
50
57
  // the spawn/ack round trip that precedes agent_start must not read as