mu-harness 0.23.0 → 0.24.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.
@@ -38,6 +38,7 @@ export async function connectHarness(opts) {
38
38
  const subSessions = new Map();
39
39
  const approvalListeners = new Set();
40
40
  const subAgentListeners = new Set();
41
+ const modelLoadingListeners = new Set();
41
42
  const forkWaiters = new Map();
42
43
  const subagentWaiters = new Map();
43
44
  const listWaiters = [];
@@ -144,6 +145,10 @@ export async function connectHarness(opts) {
144
145
  features.vision = frame.vision;
145
146
  features.audio = frame.audio;
146
147
  return;
148
+ case 'model_loading':
149
+ for (const l of [...modelLoadingListeners])
150
+ l(frame.model, frame.loading);
151
+ return;
147
152
  case 'models:listed': {
148
153
  models = frame.models.map((m) => ({ id: m.id, ownedBy: m.ownedBy }));
149
154
  modelSelected = frame.selected;
@@ -300,6 +305,10 @@ export async function connectHarness(opts) {
300
305
  saveThinking: opts.saveThinking ?? (() => { }),
301
306
  history: opts.history,
302
307
  features,
308
+ subscribeModelLoading: (listener) => {
309
+ modelLoadingListeners.add(listener);
310
+ return () => modelLoadingListeners.delete(listener);
311
+ },
303
312
  banner: opts.banner,
304
313
  minimal: opts.minimal,
305
314
  commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
@@ -168,6 +168,10 @@ export type WsOutbound = {
168
168
  type: 'capabilities';
169
169
  vision: boolean;
170
170
  audio: boolean;
171
+ } | {
172
+ type: 'model_loading';
173
+ model: string;
174
+ loading: boolean;
171
175
  } | {
172
176
  type: 'agents';
173
177
  agents: WireAgent[];
@@ -164,12 +164,19 @@ export function webSocketAdapter(opts) {
164
164
  const frame = await modelsFrame();
165
165
  if (frame)
166
166
  push(frame);
167
- // Detect the new model's input modalities (loads the model) and re-advertise.
168
- const modalities = await harness.models.capabilities(msg.ref).catch(() => undefined);
169
- if (modalities) {
170
- caps.vision = modalities.vision;
171
- caps.audio = modalities.audio;
172
- push({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
167
+ // Detecting the new model's modalities loads it (a /props round-trip can be a
168
+ // 10-30s cold start) surface that as a loading state to every channel.
169
+ push({ type: 'model_loading', model: msg.ref, loading: true });
170
+ try {
171
+ const modalities = await harness.models.capabilities(msg.ref).catch(() => undefined);
172
+ if (modalities) {
173
+ caps.vision = modalities.vision;
174
+ caps.audio = modalities.audio;
175
+ push({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
176
+ }
177
+ }
178
+ finally {
179
+ push({ type: 'model_loading', model: msg.ref, loading: false });
173
180
  }
174
181
  return;
175
182
  }
@@ -41,6 +41,8 @@ export interface ChatHost {
41
41
  append(text: string): void;
42
42
  };
43
43
  features?: ChatFeatures;
44
+ /** Subscribe to model load/unload state (cold-start). Optional — only WS-backed hosts emit it. */
45
+ subscribeModelLoading?(listener: (model: string, loading: boolean) => void): () => void;
44
46
  banner?: string;
45
47
  minimal?: boolean;
46
48
  commands?(): {
@@ -72,6 +74,7 @@ export declare class ChatApp {
72
74
  private unsubscribe;
73
75
  private unsubscribeTheme;
74
76
  private unsubscribeSubAgents;
77
+ private unsubscribeModelLoading;
75
78
  private readonly runUnsubs;
76
79
  private readonly activeRuns;
77
80
  private mentionAc;
@@ -120,6 +123,7 @@ export declare class ChatApp {
120
123
  private tryDispatch;
121
124
  private dispatchMention;
122
125
  private swapSession;
126
+ private onModelLoading;
123
127
  private handleEvent;
124
128
  private applyUsage;
125
129
  private onTurnComplete;
@@ -99,6 +99,7 @@ export class ChatApp {
99
99
  unsubscribe;
100
100
  unsubscribeTheme;
101
101
  unsubscribeSubAgents;
102
+ unsubscribeModelLoading;
102
103
  runUnsubs = new Set();
103
104
  activeRuns = new Set();
104
105
  mentionAc;
@@ -194,6 +195,7 @@ export class ChatApp {
194
195
  this.tui.setToastForeground(this.theme().colors.text);
195
196
  this.tui.requestRender(true);
196
197
  });
198
+ this.unsubscribeModelLoading = this.host.subscribeModelLoading?.((model, loading) => this.onModelLoading(model, loading));
197
199
  this.bindSession();
198
200
  if (this.feature('subAgents')) {
199
201
  this.unsubscribeSubAgents = this.host.subAgents.subscribe((run) => this.onSubAgentRun(run));
@@ -219,6 +221,7 @@ export class ChatApp {
219
221
  this.unsubscribeTheme?.();
220
222
  this.unsubscribeSubAgents?.();
221
223
  this.unsubscribeApproval?.();
224
+ this.unsubscribeModelLoading?.();
222
225
  this.clearRuns();
223
226
  this.stopSpinner();
224
227
  this.clearError();
@@ -431,6 +434,21 @@ export class ChatApp {
431
434
  this.setStatus('ready');
432
435
  this.tui.requestRender(true);
433
436
  }
437
+ onModelLoading(model, loading) {
438
+ const name = model.split('/').pop() ?? model;
439
+ if (loading) {
440
+ this.status.busy = true;
441
+ this.setStatus(`loading ${name}…`);
442
+ this.startSpinner();
443
+ }
444
+ else if (!this.running) {
445
+ // Don't clobber an in-flight turn's status when the load finishes.
446
+ this.status.busy = false;
447
+ this.stopSpinner();
448
+ this.setStatus('ready');
449
+ }
450
+ this.tui.requestRender();
451
+ }
434
452
  handleEvent(event) {
435
453
  this.updateSpeaker();
436
454
  this.transcript.applyEvent(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-harness",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Agent harness: createHarness wires mu-core into a host — XDG paths, model registry, plugins, disk-loaded agents & skills, sub-agents, sessions (JSONL + SQLite catalog), slash commands, permission/approval hooks, an optional scheduler, and a composable TUI chat app",
5
5
  "license": "MIT",
6
6
  "main": "./script/index.js",
@@ -23,8 +23,8 @@
23
23
  "@swc/wasm-typescript": "^1.15.0",
24
24
  "cli-highlight": "^2.1.11",
25
25
  "croner": "^9.0.0",
26
- "mu-core": "^0.23.0",
27
- "mu-tui": "^0.23.0",
26
+ "mu-core": "^0.24.0",
27
+ "mu-tui": "^0.24.0",
28
28
  "ws": "^8.18.0"
29
29
  },
30
30
  "_generatedBy": "dnt@dev",
@@ -41,6 +41,7 @@ async function connectHarness(opts) {
41
41
  const subSessions = new Map();
42
42
  const approvalListeners = new Set();
43
43
  const subAgentListeners = new Set();
44
+ const modelLoadingListeners = new Set();
44
45
  const forkWaiters = new Map();
45
46
  const subagentWaiters = new Map();
46
47
  const listWaiters = [];
@@ -147,6 +148,10 @@ async function connectHarness(opts) {
147
148
  features.vision = frame.vision;
148
149
  features.audio = frame.audio;
149
150
  return;
151
+ case 'model_loading':
152
+ for (const l of [...modelLoadingListeners])
153
+ l(frame.model, frame.loading);
154
+ return;
150
155
  case 'models:listed': {
151
156
  models = frame.models.map((m) => ({ id: m.id, ownedBy: m.ownedBy }));
152
157
  modelSelected = frame.selected;
@@ -303,6 +308,10 @@ async function connectHarness(opts) {
303
308
  saveThinking: opts.saveThinking ?? (() => { }),
304
309
  history: opts.history,
305
310
  features,
311
+ subscribeModelLoading: (listener) => {
312
+ modelLoadingListeners.add(listener);
313
+ return () => modelLoadingListeners.delete(listener);
314
+ },
306
315
  banner: opts.banner,
307
316
  minimal: opts.minimal,
308
317
  commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
@@ -168,6 +168,10 @@ export type WsOutbound = {
168
168
  type: 'capabilities';
169
169
  vision: boolean;
170
170
  audio: boolean;
171
+ } | {
172
+ type: 'model_loading';
173
+ model: string;
174
+ loading: boolean;
171
175
  } | {
172
176
  type: 'agents';
173
177
  agents: WireAgent[];
@@ -167,12 +167,19 @@ function webSocketAdapter(opts) {
167
167
  const frame = await modelsFrame();
168
168
  if (frame)
169
169
  push(frame);
170
- // Detect the new model's input modalities (loads the model) and re-advertise.
171
- const modalities = await harness.models.capabilities(msg.ref).catch(() => undefined);
172
- if (modalities) {
173
- caps.vision = modalities.vision;
174
- caps.audio = modalities.audio;
175
- push({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
170
+ // Detecting the new model's modalities loads it (a /props round-trip can be a
171
+ // 10-30s cold start) surface that as a loading state to every channel.
172
+ push({ type: 'model_loading', model: msg.ref, loading: true });
173
+ try {
174
+ const modalities = await harness.models.capabilities(msg.ref).catch(() => undefined);
175
+ if (modalities) {
176
+ caps.vision = modalities.vision;
177
+ caps.audio = modalities.audio;
178
+ push({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
179
+ }
180
+ }
181
+ finally {
182
+ push({ type: 'model_loading', model: msg.ref, loading: false });
176
183
  }
177
184
  return;
178
185
  }
@@ -41,6 +41,8 @@ export interface ChatHost {
41
41
  append(text: string): void;
42
42
  };
43
43
  features?: ChatFeatures;
44
+ /** Subscribe to model load/unload state (cold-start). Optional — only WS-backed hosts emit it. */
45
+ subscribeModelLoading?(listener: (model: string, loading: boolean) => void): () => void;
44
46
  banner?: string;
45
47
  minimal?: boolean;
46
48
  commands?(): {
@@ -72,6 +74,7 @@ export declare class ChatApp {
72
74
  private unsubscribe;
73
75
  private unsubscribeTheme;
74
76
  private unsubscribeSubAgents;
77
+ private unsubscribeModelLoading;
75
78
  private readonly runUnsubs;
76
79
  private readonly activeRuns;
77
80
  private mentionAc;
@@ -120,6 +123,7 @@ export declare class ChatApp {
120
123
  private tryDispatch;
121
124
  private dispatchMention;
122
125
  private swapSession;
126
+ private onModelLoading;
123
127
  private handleEvent;
124
128
  private applyUsage;
125
129
  private onTurnComplete;
@@ -102,6 +102,7 @@ class ChatApp {
102
102
  unsubscribe;
103
103
  unsubscribeTheme;
104
104
  unsubscribeSubAgents;
105
+ unsubscribeModelLoading;
105
106
  runUnsubs = new Set();
106
107
  activeRuns = new Set();
107
108
  mentionAc;
@@ -197,6 +198,7 @@ class ChatApp {
197
198
  this.tui.setToastForeground(this.theme().colors.text);
198
199
  this.tui.requestRender(true);
199
200
  });
201
+ this.unsubscribeModelLoading = this.host.subscribeModelLoading?.((model, loading) => this.onModelLoading(model, loading));
200
202
  this.bindSession();
201
203
  if (this.feature('subAgents')) {
202
204
  this.unsubscribeSubAgents = this.host.subAgents.subscribe((run) => this.onSubAgentRun(run));
@@ -222,6 +224,7 @@ class ChatApp {
222
224
  this.unsubscribeTheme?.();
223
225
  this.unsubscribeSubAgents?.();
224
226
  this.unsubscribeApproval?.();
227
+ this.unsubscribeModelLoading?.();
225
228
  this.clearRuns();
226
229
  this.stopSpinner();
227
230
  this.clearError();
@@ -434,6 +437,21 @@ class ChatApp {
434
437
  this.setStatus('ready');
435
438
  this.tui.requestRender(true);
436
439
  }
440
+ onModelLoading(model, loading) {
441
+ const name = model.split('/').pop() ?? model;
442
+ if (loading) {
443
+ this.status.busy = true;
444
+ this.setStatus(`loading ${name}…`);
445
+ this.startSpinner();
446
+ }
447
+ else if (!this.running) {
448
+ // Don't clobber an in-flight turn's status when the load finishes.
449
+ this.status.busy = false;
450
+ this.stopSpinner();
451
+ this.setStatus('ready');
452
+ }
453
+ this.tui.requestRender();
454
+ }
437
455
  handleEvent(event) {
438
456
  this.updateSpeaker();
439
457
  this.transcript.applyEvent(event);