mu-harness 0.23.0 → 0.25.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;
@@ -39,6 +39,9 @@ const AUDIO_MIME = {
39
39
  '.webm': 'audio/webm',
40
40
  };
41
41
  const ATTACHMENT_PLACEHOLDER = /\[(?:image|audio) #\d+\]/g;
42
+ // Cap raw attachment bytes so the base64 chat frame (~1.34x) stays under a typical
43
+ // 16MB WS payload limit, with headroom for text + JSON. Fail loudly, not silently.
44
+ const MAX_ATTACHMENT_BYTES = 11 * 1024 * 1024;
42
45
  const lastAssistantText = (messages) => {
43
46
  for (let i = messages.length - 1; i >= 0; i--) {
44
47
  const message = messages[i];
@@ -99,6 +102,7 @@ export class ChatApp {
99
102
  unsubscribe;
100
103
  unsubscribeTheme;
101
104
  unsubscribeSubAgents;
105
+ unsubscribeModelLoading;
102
106
  runUnsubs = new Set();
103
107
  activeRuns = new Set();
104
108
  mentionAc;
@@ -194,6 +198,7 @@ export class ChatApp {
194
198
  this.tui.setToastForeground(this.theme().colors.text);
195
199
  this.tui.requestRender(true);
196
200
  });
201
+ this.unsubscribeModelLoading = this.host.subscribeModelLoading?.((model, loading) => this.onModelLoading(model, loading));
197
202
  this.bindSession();
198
203
  if (this.feature('subAgents')) {
199
204
  this.unsubscribeSubAgents = this.host.subAgents.subscribe((run) => this.onSubAgentRun(run));
@@ -219,6 +224,7 @@ export class ChatApp {
219
224
  this.unsubscribeTheme?.();
220
225
  this.unsubscribeSubAgents?.();
221
226
  this.unsubscribeApproval?.();
227
+ this.unsubscribeModelLoading?.();
222
228
  this.clearRuns();
223
229
  this.stopSpinner();
224
230
  this.clearError();
@@ -431,6 +437,21 @@ export class ChatApp {
431
437
  this.setStatus('ready');
432
438
  this.tui.requestRender(true);
433
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
+ }
434
455
  handleEvent(event) {
435
456
  this.updateSpeaker();
436
457
  this.transcript.applyEvent(event);
@@ -566,6 +587,11 @@ export class ChatApp {
566
587
  this.tui.requestRender();
567
588
  return;
568
589
  }
590
+ if (data.byteLength > MAX_ATTACHMENT_BYTES) {
591
+ this.setStatus(`${kind} too large (${Math.round(data.byteLength / (1024 * 1024))}MB) — not attached`);
592
+ this.tui.requestRender();
593
+ return;
594
+ }
569
595
  const placeholder = `[${kind} #${++this.attachSeq}]`;
570
596
  this.attachments.set(placeholder, kind === 'image' ? image(mime, data) : audio(mime, data));
571
597
  const value = this.editor.getValue();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-harness",
3
- "version": "0.23.0",
3
+ "version": "0.25.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.25.0",
27
+ "mu-tui": "^0.25.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;
@@ -42,6 +42,9 @@ const AUDIO_MIME = {
42
42
  '.webm': 'audio/webm',
43
43
  };
44
44
  const ATTACHMENT_PLACEHOLDER = /\[(?:image|audio) #\d+\]/g;
45
+ // Cap raw attachment bytes so the base64 chat frame (~1.34x) stays under a typical
46
+ // 16MB WS payload limit, with headroom for text + JSON. Fail loudly, not silently.
47
+ const MAX_ATTACHMENT_BYTES = 11 * 1024 * 1024;
45
48
  const lastAssistantText = (messages) => {
46
49
  for (let i = messages.length - 1; i >= 0; i--) {
47
50
  const message = messages[i];
@@ -102,6 +105,7 @@ class ChatApp {
102
105
  unsubscribe;
103
106
  unsubscribeTheme;
104
107
  unsubscribeSubAgents;
108
+ unsubscribeModelLoading;
105
109
  runUnsubs = new Set();
106
110
  activeRuns = new Set();
107
111
  mentionAc;
@@ -197,6 +201,7 @@ class ChatApp {
197
201
  this.tui.setToastForeground(this.theme().colors.text);
198
202
  this.tui.requestRender(true);
199
203
  });
204
+ this.unsubscribeModelLoading = this.host.subscribeModelLoading?.((model, loading) => this.onModelLoading(model, loading));
200
205
  this.bindSession();
201
206
  if (this.feature('subAgents')) {
202
207
  this.unsubscribeSubAgents = this.host.subAgents.subscribe((run) => this.onSubAgentRun(run));
@@ -222,6 +227,7 @@ class ChatApp {
222
227
  this.unsubscribeTheme?.();
223
228
  this.unsubscribeSubAgents?.();
224
229
  this.unsubscribeApproval?.();
230
+ this.unsubscribeModelLoading?.();
225
231
  this.clearRuns();
226
232
  this.stopSpinner();
227
233
  this.clearError();
@@ -434,6 +440,21 @@ class ChatApp {
434
440
  this.setStatus('ready');
435
441
  this.tui.requestRender(true);
436
442
  }
443
+ onModelLoading(model, loading) {
444
+ const name = model.split('/').pop() ?? model;
445
+ if (loading) {
446
+ this.status.busy = true;
447
+ this.setStatus(`loading ${name}…`);
448
+ this.startSpinner();
449
+ }
450
+ else if (!this.running) {
451
+ // Don't clobber an in-flight turn's status when the load finishes.
452
+ this.status.busy = false;
453
+ this.stopSpinner();
454
+ this.setStatus('ready');
455
+ }
456
+ this.tui.requestRender();
457
+ }
437
458
  handleEvent(event) {
438
459
  this.updateSpeaker();
439
460
  this.transcript.applyEvent(event);
@@ -569,6 +590,11 @@ class ChatApp {
569
590
  this.tui.requestRender();
570
591
  return;
571
592
  }
593
+ if (data.byteLength > MAX_ATTACHMENT_BYTES) {
594
+ this.setStatus(`${kind} too large (${Math.round(data.byteLength / (1024 * 1024))}MB) — not attached`);
595
+ this.tui.requestRender();
596
+ return;
597
+ }
572
598
  const placeholder = `[${kind} #${++this.attachSeq}]`;
573
599
  this.attachments.set(placeholder, kind === 'image' ? (0, mu_core_1.image)(mime, data) : (0, mu_core_1.audio)(mime, data));
574
600
  const value = this.editor.getValue();