@femtomc/mu-server 26.2.106 → 26.2.107

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/README.md CHANGED
@@ -4,9 +4,9 @@ HTTP API server for mu control-plane infrastructure.
4
4
  Powers `mu serve`, messaging frontend transport routes, and
5
5
  control-plane/session coordination endpoints.
6
6
 
7
- > Scope note: server-routed business query/mutation gateway endpoints have
8
- > been removed. Business reads/writes are CLI-first, while long-lived runtime
9
- > coordination (runs/heartbeats/cron) stays server-owned.
7
+ > Scope note: server-routed endpoints focus on runtime coordination only.
8
+ > Business reads/writes are CLI-first, while long-lived runtime coordination
9
+ > (runs/heartbeats/cron) stays server-owned.
10
10
 
11
11
  ## Installation
12
12
 
@@ -44,7 +44,7 @@ Use `mu store paths --pretty` to resolve `<store>` for the active repo/workspace
44
44
 
45
45
  ### Health Check
46
46
 
47
- - `GET /healthz` or `GET /health` - Returns 200 OK
47
+ - `GET /healthz` - Returns 200 OK
48
48
 
49
49
  ### Status
50
50
 
@@ -256,6 +256,6 @@ The server uses:
256
256
  - Filesystem-backed JSONL event storage (FsJsonlStore)
257
257
  - Bun's built-in HTTP server
258
258
  - Control-plane adapter/webhook transport + session coordination routes
259
- - Generation-supervised control-plane hot reload lifecycle (see `docs/adr-0001-control-plane-hot-reload.md`)
259
+ - Generation-supervised control-plane hot reload lifecycle
260
260
 
261
261
  Control-plane/coordination data is persisted under `<store>/` (for example `<store>/events.jsonl` and `<store>/control-plane/*`). Use `mu store paths` to resolve `<store>` for your repo.
@@ -1,4 +1,5 @@
1
1
  import { CONTROL_PLANE_CHANNEL_ADAPTER_SPECS, ingressModeForValue, summarizeInboundAttachmentPolicy, } from "@femtomc/mu-control-plane";
2
+ import { UI_ACTIONS_UNSUPPORTED_REASON, UI_COMPONENT_SUPPORT } from "../control_plane.js";
2
3
  import { configRoutes } from "./config.js";
3
4
  import { eventRoutes } from "./events.js";
4
5
  import { identityRoutes } from "./identities.js";
@@ -92,9 +93,70 @@ function mediaInboundAttachmentCapability(config, channel) {
92
93
  reason: hasToken ? null : "telegram_bot_token_missing",
93
94
  };
94
95
  }
96
+ const TEXT_ONLY_UI_COMPONENT_SUPPORT = {
97
+ text: true,
98
+ list: false,
99
+ key_value: false,
100
+ divider: false,
101
+ };
102
+ const CHANNEL_UI_CAPABILITIES = {
103
+ slack: {
104
+ supported: true,
105
+ reason: null,
106
+ components: UI_COMPONENT_SUPPORT,
107
+ actions: {
108
+ supported: true,
109
+ reason: null,
110
+ },
111
+ },
112
+ discord: {
113
+ supported: true,
114
+ reason: null,
115
+ components: TEXT_ONLY_UI_COMPONENT_SUPPORT,
116
+ actions: {
117
+ supported: true,
118
+ reason: null,
119
+ },
120
+ },
121
+ telegram: {
122
+ supported: true,
123
+ reason: null,
124
+ components: TEXT_ONLY_UI_COMPONENT_SUPPORT,
125
+ actions: {
126
+ supported: true,
127
+ reason: null,
128
+ },
129
+ },
130
+ neovim: {
131
+ supported: true,
132
+ reason: null,
133
+ components: TEXT_ONLY_UI_COMPONENT_SUPPORT,
134
+ actions: {
135
+ supported: true,
136
+ reason: null,
137
+ },
138
+ },
139
+ terminal: {
140
+ supported: true,
141
+ reason: null,
142
+ components: TEXT_ONLY_UI_COMPONENT_SUPPORT,
143
+ actions: {
144
+ supported: false,
145
+ reason: UI_ACTIONS_UNSUPPORTED_REASON,
146
+ },
147
+ },
148
+ };
149
+ function uiCapabilityForChannel(channel) {
150
+ return CHANNEL_UI_CAPABILITIES[channel] ?? {
151
+ supported: true,
152
+ reason: null,
153
+ components: TEXT_ONLY_UI_COMPONENT_SUPPORT,
154
+ actions: { supported: false, reason: UI_ACTIONS_UNSUPPORTED_REASON },
155
+ };
156
+ }
95
157
  export async function controlPlaneRoutes(request, url, deps, headers) {
96
158
  const path = url.pathname;
97
- if (path === "/api/control-plane" || path === "/api/control-plane/status") {
159
+ if (path === "/api/control-plane/status") {
98
160
  if (request.method !== "GET") {
99
161
  return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
100
162
  }
@@ -164,6 +226,7 @@ export async function controlPlaneRoutes(request, url, deps, headers) {
164
226
  outbound_delivery: mediaOutboundCapability(config, spec.channel),
165
227
  inbound_attachment_download: mediaInboundAttachmentCapability(config, spec.channel),
166
228
  },
229
+ ui: uiCapabilityForChannel(spec.channel),
167
230
  }));
168
231
  return Response.json({
169
232
  ok: true,
@@ -1,5 +1,6 @@
1
1
  import type { MessagingOperatorBackend, MessagingOperatorRuntime } from "@femtomc/mu-agent";
2
- import { type GenerationTelemetryRecorder, type OutboxDeliveryHandlerResult, type OutboxRecord } from "@femtomc/mu-control-plane";
2
+ import { type UiDoc, type UiEvent } from "@femtomc/mu-core";
3
+ import { type GenerationTelemetryRecorder, type OutboxDeliveryHandlerResult, type OutboxRecord, UiCallbackTokenStore } from "@femtomc/mu-control-plane";
3
4
  import { type ControlPlaneConfig, type ControlPlaneGenerationContext, type ControlPlaneHandle, type ControlPlaneSessionLifecycle, type TelegramGenerationSwapHooks, type WakeDeliveryObserver } from "./control_plane_contract.js";
4
5
  import { detectAdapters } from "./control_plane_adapter_registry.js";
5
6
  export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneGenerationContext, ControlPlaneHandle, ControlPlaneSessionLifecycle, ControlPlaneSessionMutationAction, ControlPlaneSessionMutationResult, NotifyOperatorsOpts, NotifyOperatorsResult, TelegramGenerationReloadResult, TelegramGenerationRollbackTrigger, TelegramGenerationSwapHooks, WakeDeliveryEvent, WakeDeliveryObserver, WakeNotifyContext, WakeNotifyDecision, } from "./control_plane_contract.js";
@@ -52,8 +53,22 @@ export declare function buildTelegramSendMessagePayload(opts: {
52
53
  richFormatting: boolean;
53
54
  }): TelegramSendMessagePayload;
54
55
  export declare function splitTelegramMessageText(text: string, maxLen?: number): string[];
56
+ export declare const UI_COMPONENT_SUPPORT: {
57
+ readonly text: true;
58
+ readonly list: true;
59
+ readonly key_value: true;
60
+ readonly divider: true;
61
+ };
62
+ export declare const UI_ACTIONS_UNSUPPORTED_REASON = "ui_actions_not_implemented";
63
+ export declare function uiDocTextLines(doc: UiDoc, opts?: {
64
+ includeActions?: boolean;
65
+ }): string[];
66
+ export declare function uiDocsTextFallback(uiDocs: readonly UiDoc[]): string;
55
67
  export declare function splitSlackMessageText(text: string, maxLen?: number): string[];
56
- type TelegramCallbackDataEncoder = (commandText: string) => Promise<string>;
68
+ type TelegramCallbackDataEncoder = (commandText: string, opts: {
69
+ record: OutboxRecord;
70
+ uiEvent?: UiEvent;
71
+ }) => Promise<string>;
57
72
  export declare function deliverTelegramOutboxRecord(opts: {
58
73
  botToken: string;
59
74
  record: OutboxRecord;
@@ -62,6 +77,9 @@ export declare function deliverTelegramOutboxRecord(opts: {
62
77
  export declare function deliverSlackOutboxRecord(opts: {
63
78
  botToken: string;
64
79
  record: OutboxRecord;
80
+ uiCallbackTokenStore?: UiCallbackTokenStore;
81
+ nowMs?: () => number;
82
+ fetchImpl?: typeof fetch;
65
83
  }): Promise<OutboxDeliveryHandlerResult>;
66
84
  export type BootstrapControlPlaneOpts = {
67
85
  repoRoot: string;
@@ -1,5 +1,5 @@
1
- import { applyHudStylePreset, normalizeHudDocs } from "@femtomc/mu-core";
2
- import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneRuntime, buildSlackHudActionId, getControlPlanePaths, TelegramControlPlaneAdapterSpec, } from "@femtomc/mu-control-plane";
1
+ import { applyHudStylePreset, normalizeHudDocs, normalizeUiDocs, } from "@femtomc/mu-core";
2
+ import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneRuntime, buildSanitizedUiEventForAction, buildSlackHudActionId, getControlPlanePaths, issueUiDocActionPayloads, TelegramControlPlaneAdapterSpec, uiActionPayloadContextFromOutboxRecord, uiDocActionPayloadKey, UiCallbackTokenStore, } from "@femtomc/mu-control-plane";
3
3
  import { DEFAULT_MU_CONFIG } from "./config.js";
4
4
  import { buildMessagingOperatorRuntime, createOutboxDrainLoop } from "./control_plane_bootstrap_helpers.js";
5
5
  import { buildWakeOutboundEnvelope, resolveWakeFanoutCapability, wakeDeliveryMetadataFromOutboxRecord, wakeDispatchReasonCode, wakeFanoutDedupeKey, } from "./control_plane_wake_delivery.js";
@@ -146,8 +146,19 @@ const SLACK_BLOCK_TEXT_MAX_LEN = 3_000;
146
146
  const SLACK_BLOCKS_MAX = 50;
147
147
  const SLACK_ACTIONS_MAX_PER_BLOCK = 5;
148
148
  const SLACK_ACTIONS_MAX_TOTAL = 20;
149
+ const SLACK_ACTION_VALUE_MAX_CHARS = 2_000;
150
+ const SLACK_UI_EVENT_ACTION_ID = buildSlackHudActionId("ui_event");
149
151
  const SLACK_DOCS_MAX = 3;
150
152
  const SLACK_SECTION_LINE_MAX = 8;
153
+ const UI_DOCS_MAX = 3;
154
+ const UI_CALLBACK_TOKEN_TTL_MS = 15 * 60_000;
155
+ export const UI_COMPONENT_SUPPORT = {
156
+ text: true,
157
+ list: true,
158
+ key_value: true,
159
+ divider: true,
160
+ };
161
+ export const UI_ACTIONS_UNSUPPORTED_REASON = "ui_actions_not_implemented";
151
162
  function hudDocsForPresentation(input, maxDocs) {
152
163
  return normalizeHudDocs(input, { maxDocs }).map((doc) => applyHudStylePreset(doc) ?? doc);
153
164
  }
@@ -257,6 +268,88 @@ function hudDocSectionLines(doc) {
257
268
  }
258
269
  return lines;
259
270
  }
271
+ function uiDocComponentLines(doc) {
272
+ const lines = [];
273
+ const components = [...doc.components].sort((a, b) => a.id.localeCompare(b.id));
274
+ for (const component of components) {
275
+ switch (component.kind) {
276
+ case "text": {
277
+ lines.push(component.text);
278
+ break;
279
+ }
280
+ case "list": {
281
+ if (component.title) {
282
+ lines.push(component.title);
283
+ }
284
+ for (const item of component.items) {
285
+ lines.push(`• ${item.label}${item.detail ? ` · ${item.detail}` : ""}`);
286
+ }
287
+ break;
288
+ }
289
+ case "key_value": {
290
+ if (component.title) {
291
+ lines.push(component.title);
292
+ }
293
+ for (const row of component.rows) {
294
+ lines.push(`• ${row.key}: ${row.value}`);
295
+ }
296
+ break;
297
+ }
298
+ case "divider": {
299
+ lines.push("────────");
300
+ break;
301
+ }
302
+ }
303
+ }
304
+ return lines;
305
+ }
306
+ function uiDocActionLines(doc) {
307
+ const actions = [...doc.actions].sort((a, b) => a.id.localeCompare(b.id));
308
+ return actions.map((action) => {
309
+ const parts = [`• ${action.label}`];
310
+ if (action.description) {
311
+ parts.push(action.description);
312
+ }
313
+ parts.push(`(id=${action.id})`);
314
+ return parts.join(" ");
315
+ });
316
+ }
317
+ export function uiDocTextLines(doc, opts = {}) {
318
+ const lines = [`UI · ${doc.title}`];
319
+ if (doc.summary) {
320
+ lines.push(doc.summary);
321
+ }
322
+ const componentLines = uiDocComponentLines(doc);
323
+ if (componentLines.length > 0) {
324
+ lines.push(...componentLines);
325
+ }
326
+ if (opts.includeActions !== false) {
327
+ const actionLines = uiDocActionLines(doc);
328
+ if (actionLines.length > 0) {
329
+ lines.push("Actions:");
330
+ lines.push(...actionLines);
331
+ }
332
+ }
333
+ return lines;
334
+ }
335
+ export function uiDocsTextFallback(uiDocs) {
336
+ if (uiDocs.length === 0) {
337
+ return "";
338
+ }
339
+ const sections = uiDocs.map((doc) => uiDocTextLines(doc).join("\n"));
340
+ return sections.join("\n\n");
341
+ }
342
+ function appendUiDocText(body, uiDocs) {
343
+ const fallback = uiDocsTextFallback(uiDocs);
344
+ if (!fallback) {
345
+ return body;
346
+ }
347
+ const trimmed = body.trim();
348
+ if (trimmed.length === 0) {
349
+ return fallback;
350
+ }
351
+ return `${trimmed}\n\n${fallback}`;
352
+ }
260
353
  function hudActionButtons(doc) {
261
354
  return doc.actions.slice(0, SLACK_ACTIONS_MAX_TOTAL).map((action) => ({
262
355
  type: "button",
@@ -264,10 +357,44 @@ function hudActionButtons(doc) {
264
357
  type: "plain_text",
265
358
  text: truncateSlackText(action.label, 75),
266
359
  },
267
- value: truncateSlackText(action.command_text, 2_000),
360
+ value: truncateSlackText(action.command_text, SLACK_ACTION_VALUE_MAX_CHARS),
268
361
  action_id: buildSlackHudActionId(action.id),
269
362
  }));
270
363
  }
364
+ function uiDocActionTextLine(action, opts = {}) {
365
+ const parts = [`• ${action.label}`];
366
+ if (action.description) {
367
+ parts.push(action.description);
368
+ }
369
+ parts.push(`(id=${action.id}${opts.suffix ? `; ${opts.suffix}` : ""})`);
370
+ return parts.join(" ");
371
+ }
372
+ function uiDocActionButtons(doc, actionPayloadsByKey) {
373
+ const buttons = [];
374
+ const fallbackLines = [];
375
+ const actions = [...doc.actions].sort((a, b) => a.id.localeCompare(b.id)).slice(0, SLACK_ACTIONS_MAX_TOTAL);
376
+ for (const action of actions) {
377
+ const payload = actionPayloadsByKey.get(uiDocActionPayloadKey(doc.ui_id, action.id));
378
+ if (!payload) {
379
+ fallbackLines.push(uiDocActionTextLine(action, { suffix: "interactive unavailable" }));
380
+ continue;
381
+ }
382
+ if (payload.length > SLACK_ACTION_VALUE_MAX_CHARS) {
383
+ fallbackLines.push(uiDocActionTextLine(action, { suffix: "interactive payload too large" }));
384
+ continue;
385
+ }
386
+ buttons.push({
387
+ type: "button",
388
+ text: {
389
+ type: "plain_text",
390
+ text: truncateSlackText(action.label, 75),
391
+ },
392
+ value: payload,
393
+ action_id: SLACK_UI_EVENT_ACTION_ID,
394
+ });
395
+ }
396
+ return { buttons, fallbackLines };
397
+ }
271
398
  export function splitSlackMessageText(text, maxLen = SLACK_MESSAGE_MAX_LEN) {
272
399
  if (text.length <= maxLen) {
273
400
  return [text];
@@ -288,15 +415,17 @@ export function splitSlackMessageText(text, maxLen = SLACK_MESSAGE_MAX_LEN) {
288
415
  }
289
416
  return chunks;
290
417
  }
291
- function slackBlocksForOutboxRecord(record, body) {
418
+ function slackBlocksForOutboxRecord(record, body, uiDocs, opts = {}) {
292
419
  const hudDocs = hudDocsForPresentation(record.envelope.metadata?.hud_docs, SLACK_DOCS_MAX);
293
- if (hudDocs.length === 0) {
420
+ if (hudDocs.length === 0 && uiDocs.length === 0) {
294
421
  return undefined;
295
422
  }
423
+ const uiDocActionPayloadsByKey = opts.uiDocActionPayloadsByKey ?? new Map();
296
424
  const blocks = [];
425
+ const headerText = body.trim().length > 0 ? body : "Update";
297
426
  blocks.push({
298
427
  type: "section",
299
- text: { type: "mrkdwn", text: truncateSlackText(body) },
428
+ text: { type: "mrkdwn", text: truncateSlackText(headerText) },
300
429
  });
301
430
  for (const doc of hudDocs) {
302
431
  if (blocks.length >= SLACK_BLOCKS_MAX) {
@@ -327,16 +456,53 @@ function slackBlocksForOutboxRecord(record, body) {
327
456
  });
328
457
  }
329
458
  }
459
+ for (const doc of uiDocs) {
460
+ if (blocks.length >= SLACK_BLOCKS_MAX) {
461
+ break;
462
+ }
463
+ const lines = uiDocTextLines(doc, { includeActions: false });
464
+ blocks.push({
465
+ type: "context",
466
+ elements: [{ type: "mrkdwn", text: truncateSlackText(lines[0]) }],
467
+ });
468
+ for (const line of lines.slice(1)) {
469
+ if (blocks.length >= SLACK_BLOCKS_MAX) {
470
+ break;
471
+ }
472
+ blocks.push({
473
+ type: "section",
474
+ text: { type: "mrkdwn", text: truncateSlackText(line) },
475
+ });
476
+ }
477
+ const actionRender = uiDocActionButtons(doc, uiDocActionPayloadsByKey);
478
+ for (let idx = 0; idx < actionRender.buttons.length; idx += SLACK_ACTIONS_MAX_PER_BLOCK) {
479
+ if (blocks.length >= SLACK_BLOCKS_MAX) {
480
+ break;
481
+ }
482
+ blocks.push({
483
+ type: "actions",
484
+ elements: actionRender.buttons.slice(idx, idx + SLACK_ACTIONS_MAX_PER_BLOCK),
485
+ });
486
+ }
487
+ if (actionRender.fallbackLines.length > 0 && blocks.length < SLACK_BLOCKS_MAX) {
488
+ blocks.push({
489
+ type: "section",
490
+ text: {
491
+ type: "mrkdwn",
492
+ text: truncateSlackText(`Actions:\n${actionRender.fallbackLines.join("\n")}`),
493
+ },
494
+ });
495
+ }
496
+ }
330
497
  return blocks.slice(0, SLACK_BLOCKS_MAX);
331
498
  }
332
499
  function slackThreadTsFromMetadata(metadata) {
333
- const candidates = [metadata?.slack_thread_ts, metadata?.slack_message_ts, metadata?.thread_ts];
334
- for (const value of candidates) {
335
- if (typeof value === "string" && value.trim().length > 0) {
336
- return value.trim();
337
- }
500
+ const value = metadata?.slack_thread_ts;
501
+ if (typeof value !== "string") {
502
+ return undefined;
338
503
  }
339
- return undefined;
504
+ const trimmed = value.trim();
505
+ return trimmed.length > 0 ? trimmed : undefined;
340
506
  }
341
507
  function slackStatusMessageTsFromMetadata(metadata) {
342
508
  const value = metadata?.slack_status_message_ts;
@@ -353,57 +519,135 @@ const TELEGRAM_DOCS_MAX = 2;
353
519
  function utf8ByteLength(value) {
354
520
  return new TextEncoder().encode(value).length;
355
521
  }
356
- function telegramTextForOutboxRecord(record, body) {
522
+ function telegramTextForOutboxRecord(record, body, uiDocs) {
357
523
  const hudDocs = hudDocsForPresentation(record.envelope.metadata?.hud_docs, TELEGRAM_DOCS_MAX);
358
- if (hudDocs.length === 0) {
359
- return body;
360
- }
361
524
  const lines = [body.trim()];
362
- for (const doc of hudDocs) {
363
- lines.push("", `HUD · ${doc.title}`);
364
- for (const sectionLine of hudDocSectionLines(doc)) {
365
- lines.push(stripSlackMrkdwn(sectionLine));
525
+ if (hudDocs.length > 0) {
526
+ for (const doc of hudDocs) {
527
+ lines.push("", `HUD · ${doc.title}`);
528
+ for (const sectionLine of hudDocSectionLines(doc)) {
529
+ lines.push(stripSlackMrkdwn(sectionLine));
530
+ }
366
531
  }
367
532
  }
533
+ const uiFallback = uiDocsTextFallback(uiDocs);
534
+ if (uiFallback) {
535
+ lines.push("", uiFallback);
536
+ }
368
537
  return lines.join("\n").trim();
369
538
  }
539
+ function telegramReplyMarkupFromButtons(buttons) {
540
+ if (buttons.length === 0) {
541
+ return undefined;
542
+ }
543
+ const inline_keyboard = [];
544
+ for (let idx = 0; idx < buttons.length; idx += TELEGRAM_ACTIONS_PER_ROW) {
545
+ inline_keyboard.push(buttons.slice(idx, idx + TELEGRAM_ACTIONS_PER_ROW));
546
+ }
547
+ return { inline_keyboard };
548
+ }
549
+ function telegramOverflowText(lines) {
550
+ if (lines.length === 0) {
551
+ return "";
552
+ }
553
+ return `\n\nActions:\n${lines.join("\n")}`;
554
+ }
370
555
  async function compileTelegramHudActions(opts) {
371
556
  const hudDocs = hudDocsForPresentation(opts.record.envelope.metadata?.hud_docs, TELEGRAM_DOCS_MAX);
372
557
  if (hudDocs.length === 0) {
373
- return { overflowText: "" };
558
+ return { buttonEntries: [], overflowLines: [] };
374
559
  }
375
- const buttons = [];
560
+ const buttonEntries = [];
376
561
  const overflowLines = [];
377
562
  const actions = hudDocs.flatMap((doc) => doc.actions).slice(0, TELEGRAM_ACTIONS_MAX_TOTAL);
378
563
  for (const action of actions) {
564
+ const fallbackLine = `• ${action.label}: ${action.command_text}`;
379
565
  let callbackData = action.command_text;
380
566
  if (opts.encodeCallbackData) {
381
567
  try {
382
- callbackData = await opts.encodeCallbackData(action.command_text);
568
+ callbackData = await opts.encodeCallbackData(action.command_text, { record: opts.record });
383
569
  }
384
570
  catch {
385
- overflowLines.push(`• ${action.label}: ${action.command_text}`);
571
+ overflowLines.push(fallbackLine);
386
572
  continue;
387
573
  }
388
574
  }
389
575
  if (utf8ByteLength(callbackData) > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
390
- overflowLines.push(`• ${action.label}: ${action.command_text}`);
576
+ overflowLines.push(fallbackLine);
391
577
  continue;
392
578
  }
393
- buttons.push({
394
- text: action.label.slice(0, 64),
395
- callback_data: callbackData,
579
+ buttonEntries.push({
580
+ button: {
581
+ text: action.label.slice(0, 64),
582
+ callback_data: callbackData,
583
+ },
584
+ fallbackLine,
396
585
  });
397
586
  }
398
- const inline_keyboard = [];
399
- for (let idx = 0; idx < buttons.length; idx += TELEGRAM_ACTIONS_PER_ROW) {
400
- inline_keyboard.push(buttons.slice(idx, idx + TELEGRAM_ACTIONS_PER_ROW));
401
- }
402
587
  return {
403
- replyMarkup: inline_keyboard.length > 0 ? { inline_keyboard } : undefined,
404
- overflowText: overflowLines.length > 0 ? `\n\nActions:\n${overflowLines.join("\n")}` : "",
588
+ buttonEntries,
589
+ overflowLines,
405
590
  };
406
591
  }
592
+ function commandTextForUiDocAction(action) {
593
+ const fromMetadata = typeof action.metadata.command_text === "string" ? action.metadata.command_text.trim() : "";
594
+ if (fromMetadata.length === 0) {
595
+ return null;
596
+ }
597
+ return fromMetadata;
598
+ }
599
+ async function compileTelegramUiDocActions(opts) {
600
+ if (opts.uiDocs.length === 0) {
601
+ return { buttonEntries: [], overflowLines: [] };
602
+ }
603
+ const buttonEntries = [];
604
+ const overflowLines = [];
605
+ for (const doc of opts.uiDocs) {
606
+ const actions = [...doc.actions].sort((a, b) => a.id.localeCompare(b.id));
607
+ for (const action of actions) {
608
+ const uiEvent = buildSanitizedUiEventForAction({
609
+ doc,
610
+ action,
611
+ createdAtMs: opts.nowMs,
612
+ });
613
+ const commandText = commandTextForUiDocAction(action);
614
+ const fallbackLine = commandText
615
+ ? `• ${action.label}: ${commandText}`
616
+ : `• ${action.label}: interactive unavailable (missing command_text)`;
617
+ if (!commandText || !uiEvent) {
618
+ overflowLines.push(fallbackLine);
619
+ continue;
620
+ }
621
+ if (!opts.encodeCallbackData) {
622
+ overflowLines.push(fallbackLine);
623
+ continue;
624
+ }
625
+ let callbackData;
626
+ try {
627
+ callbackData = await opts.encodeCallbackData(commandText, {
628
+ record: opts.record,
629
+ uiEvent,
630
+ });
631
+ }
632
+ catch {
633
+ overflowLines.push(fallbackLine);
634
+ continue;
635
+ }
636
+ if (utf8ByteLength(callbackData) > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
637
+ overflowLines.push(fallbackLine);
638
+ continue;
639
+ }
640
+ buttonEntries.push({
641
+ button: {
642
+ text: action.label.slice(0, 64),
643
+ callback_data: callbackData,
644
+ },
645
+ fallbackLine,
646
+ });
647
+ }
648
+ }
649
+ return { buttonEntries, overflowLines };
650
+ }
407
651
  async function postTelegramMessage(botToken, payload) {
408
652
  return await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
409
653
  method: "POST",
@@ -492,13 +736,28 @@ async function sendTelegramTextChunks(opts) {
492
736
  }
493
737
  export async function deliverTelegramOutboxRecord(opts) {
494
738
  const { botToken, record } = opts;
739
+ const uiDocs = normalizeUiDocs(record.envelope.metadata?.ui_docs, { maxDocs: TELEGRAM_DOCS_MAX });
740
+ const nowMs = Math.trunc(Date.now());
495
741
  const hudActions = await compileTelegramHudActions({
496
742
  record,
497
743
  encodeCallbackData: opts.encodeCallbackData,
498
744
  });
499
- const replyMarkup = hudActions.replyMarkup;
745
+ const uiActions = await compileTelegramUiDocActions({
746
+ record,
747
+ uiDocs,
748
+ nowMs,
749
+ encodeCallbackData: opts.encodeCallbackData,
750
+ });
751
+ const combinedEntries = [...hudActions.buttonEntries, ...uiActions.buttonEntries];
752
+ const visibleEntries = combinedEntries.slice(0, TELEGRAM_ACTIONS_MAX_TOTAL);
753
+ const overflowLines = [
754
+ ...hudActions.overflowLines,
755
+ ...uiActions.overflowLines,
756
+ ...combinedEntries.slice(TELEGRAM_ACTIONS_MAX_TOTAL).map((entry) => entry.fallbackLine),
757
+ ];
758
+ const replyMarkup = telegramReplyMarkupFromButtons(visibleEntries.map((entry) => entry.button));
500
759
  const replyToMessageId = maybeParseTelegramMessageId(record.envelope.metadata?.telegram_reply_to_message_id);
501
- const telegramText = `${telegramTextForOutboxRecord(record, record.envelope.body)}${hudActions.overflowText}`.trim();
760
+ const telegramText = `${telegramTextForOutboxRecord(record, record.envelope.body, uiDocs)}${telegramOverflowText(overflowLines)}`.trim();
502
761
  const fallbackMessagePayload = {
503
762
  ...buildTelegramSendMessagePayload({
504
763
  chatId: record.envelope.channel_conversation_id,
@@ -640,7 +899,8 @@ export async function deliverTelegramOutboxRecord(opts) {
640
899
  };
641
900
  }
642
901
  async function postSlackJson(opts) {
643
- const response = await fetch(`https://slack.com/api/${opts.method}`, {
902
+ const fetchImpl = opts.fetchImpl ?? fetch;
903
+ const response = await fetchImpl(`https://slack.com/api/${opts.method}`, {
644
904
  method: "POST",
645
905
  headers: {
646
906
  Authorization: `Bearer ${opts.botToken}`,
@@ -653,10 +913,31 @@ async function postSlackJson(opts) {
653
913
  }
654
914
  export async function deliverSlackOutboxRecord(opts) {
655
915
  const { botToken, record } = opts;
916
+ const fetchImpl = opts.fetchImpl ?? fetch;
656
917
  const attachments = record.envelope.attachments ?? [];
657
- const renderedBody = renderSlackMarkdown(record.envelope.body);
918
+ const uiDocs = normalizeUiDocs(record.envelope.metadata?.ui_docs, { maxDocs: UI_DOCS_MAX });
919
+ let uiDocActionPayloadsByKey = new Map();
920
+ if (opts.uiCallbackTokenStore && uiDocs.some((doc) => doc.actions.length > 0)) {
921
+ const issued = await issueUiDocActionPayloads({
922
+ uiDocs,
923
+ tokenStore: opts.uiCallbackTokenStore,
924
+ context: uiActionPayloadContextFromOutboxRecord(record),
925
+ ttlMs: UI_CALLBACK_TOKEN_TTL_MS,
926
+ nowMs: Math.trunc((opts.nowMs ?? Date.now)()),
927
+ });
928
+ uiDocActionPayloadsByKey = new Map(issued.map((entry) => [entry.key, entry.payload_json]));
929
+ }
930
+ const renderedBodyForBlocks = renderSlackMarkdown(record.envelope.body);
931
+ const blocks = slackBlocksForOutboxRecord(record, renderedBodyForBlocks, uiDocs, {
932
+ uiDocActionPayloadsByKey,
933
+ });
934
+ const bodyForText = blocks
935
+ ? record.envelope.body.trim().length > 0
936
+ ? record.envelope.body
937
+ : "Update"
938
+ : appendUiDocText(record.envelope.body, uiDocs);
939
+ const renderedBody = renderSlackMarkdown(bodyForText);
658
940
  const textChunks = splitSlackMessageText(renderedBody);
659
- const blocks = slackBlocksForOutboxRecord(record, renderedBody);
660
941
  const threadTs = slackThreadTsFromMetadata(record.envelope.metadata);
661
942
  const statusMessageTs = slackStatusMessageTsFromMetadata(record.envelope.metadata);
662
943
  if (attachments.length === 0) {
@@ -673,6 +954,7 @@ export async function deliverSlackOutboxRecord(opts) {
673
954
  unfurl_media: false,
674
955
  ...(blocks ? { blocks } : {}),
675
956
  },
957
+ fetchImpl,
676
958
  });
677
959
  if (updated.response.ok && updated.payload?.ok) {
678
960
  chunkStartIndex = 1;
@@ -707,6 +989,7 @@ export async function deliverSlackOutboxRecord(opts) {
707
989
  ...(index === 0 && blocks ? { blocks } : {}),
708
990
  ...(threadTs ? { thread_ts: threadTs } : {}),
709
991
  },
992
+ fetchImpl,
710
993
  });
711
994
  if (delivered.response.ok && delivered.payload?.ok) {
712
995
  continue;
@@ -733,7 +1016,7 @@ export async function deliverSlackOutboxRecord(opts) {
733
1016
  error: `slack attachment ${index + 1} missing reference.url`,
734
1017
  };
735
1018
  }
736
- const source = await fetch(referenceUrl);
1019
+ const source = await fetchImpl(referenceUrl);
737
1020
  if (!source.ok) {
738
1021
  const sourceErr = await source.text().catch(() => "");
739
1022
  if (source.status === 429 || source.status >= 500) {
@@ -759,7 +1042,7 @@ export async function deliverSlackOutboxRecord(opts) {
759
1042
  form.set("thread_ts", threadTs);
760
1043
  }
761
1044
  form.set("file", new Blob([bytes], { type: contentType }), filename);
762
- const uploaded = await fetch("https://slack.com/api/files.upload", {
1045
+ const uploaded = await fetchImpl("https://slack.com/api/files.upload", {
763
1046
  method: "POST",
764
1047
  headers: {
765
1048
  Authorization: `Bearer ${botToken}`,
@@ -797,6 +1080,7 @@ export async function deliverSlackOutboxRecord(opts) {
797
1080
  unfurl_media: false,
798
1081
  ...(threadTs ? { thread_ts: threadTs } : {}),
799
1082
  },
1083
+ fetchImpl,
800
1084
  });
801
1085
  if (fallback.response.ok && fallback.payload?.ok) {
802
1086
  continue;
@@ -840,6 +1124,7 @@ export async function bootstrapControlPlane(opts) {
840
1124
  return null;
841
1125
  }
842
1126
  const paths = getControlPlanePaths(opts.repoRoot);
1127
+ const uiCallbackTokenStore = new UiCallbackTokenStore(paths.uiCallbackTokenPath);
843
1128
  const runtime = new ControlPlaneRuntime({ repoRoot: opts.repoRoot });
844
1129
  let pipeline = null;
845
1130
  let outboxDrainLoop = null;
@@ -847,6 +1132,7 @@ export async function bootstrapControlPlane(opts) {
847
1132
  const outboundDeliveryChannels = new Set();
848
1133
  const adapterMap = new Map();
849
1134
  try {
1135
+ await uiCallbackTokenStore.load();
850
1136
  await runtime.start();
851
1137
  const operator = opts.operatorRuntime !== undefined
852
1138
  ? opts.operatorRuntime
@@ -868,6 +1154,7 @@ export async function bootstrapControlPlane(opts) {
868
1154
  const telegramManager = new TelegramAdapterGenerationManager({
869
1155
  pipeline,
870
1156
  outbox,
1157
+ uiCallbackTokenStore,
871
1158
  initialConfig: controlPlaneConfig,
872
1159
  onOutboxEnqueued: () => {
873
1160
  scheduleOutboxDrainRef?.();
@@ -881,6 +1168,7 @@ export async function bootstrapControlPlane(opts) {
881
1168
  config: controlPlaneConfig,
882
1169
  pipeline,
883
1170
  outbox,
1171
+ uiCallbackTokenStore,
884
1172
  })) {
885
1173
  const route = adapter.spec.route;
886
1174
  if (adapterMap.has(route)) {
@@ -939,6 +1227,7 @@ export async function bootstrapControlPlane(opts) {
939
1227
  return await deliverSlackOutboxRecord({
940
1228
  botToken: slackBotToken,
941
1229
  record,
1230
+ uiCallbackTokenStore,
942
1231
  });
943
1232
  },
944
1233
  },
@@ -952,12 +1241,18 @@ export async function bootstrapControlPlane(opts) {
952
1241
  return await deliverTelegramOutboxRecord({
953
1242
  botToken: telegramBotToken,
954
1243
  record,
955
- encodeCallbackData: async (commandText) => {
1244
+ encodeCallbackData: async (commandText, encodeOpts) => {
956
1245
  const active = telegramManager.activeAdapter();
957
1246
  if (!active) {
958
1247
  return commandText;
959
1248
  }
960
- return await active.issueCallbackToken({ commandText });
1249
+ return await active.issueCallbackToken({
1250
+ commandText,
1251
+ actorId: encodeOpts.record.envelope.correlation.actor_id,
1252
+ actorBindingId: encodeOpts.record.envelope.correlation.actor_binding_id,
1253
+ conversationId: encodeOpts.record.envelope.channel_conversation_id,
1254
+ uiEvent: encodeOpts.uiEvent,
1255
+ });
961
1256
  },
962
1257
  });
963
1258
  },
@@ -1,4 +1,4 @@
1
- import { type ControlPlaneAdapter, type ControlPlaneCommandPipeline, type ControlPlaneOutbox } from "@femtomc/mu-control-plane";
1
+ import { type ControlPlaneAdapter, type ControlPlaneCommandPipeline, type ControlPlaneOutbox, UiCallbackTokenStore } from "@femtomc/mu-control-plane";
2
2
  import type { ControlPlaneConfig } from "./control_plane_contract.js";
3
3
  export type DetectedStaticAdapter = {
4
4
  name: "slack" | "discord" | "neovim";
@@ -16,4 +16,5 @@ export declare function createStaticAdaptersFromDetected(opts: {
16
16
  config: ControlPlaneConfig;
17
17
  pipeline: ControlPlaneCommandPipeline;
18
18
  outbox: ControlPlaneOutbox;
19
+ uiCallbackTokenStore: UiCallbackTokenStore;
19
20
  }): ControlPlaneAdapter[];
@@ -8,6 +8,7 @@ const STATIC_ADAPTER_MODULES = [
8
8
  outbox: opts.outbox,
9
9
  signingSecret: opts.secret,
10
10
  botToken: opts.config.adapters.slack.bot_token,
11
+ uiCallbackTokenStore: opts.uiCallbackTokenStore,
11
12
  }),
12
13
  },
13
14
  {
@@ -17,6 +18,7 @@ const STATIC_ADAPTER_MODULES = [
17
18
  pipeline: opts.pipeline,
18
19
  outbox: opts.outbox,
19
20
  signingSecret: opts.secret,
21
+ uiCallbackTokenStore: opts.uiCallbackTokenStore,
20
22
  }),
21
23
  },
22
24
  {
@@ -25,6 +27,7 @@ const STATIC_ADAPTER_MODULES = [
25
27
  create: (opts) => new NeovimControlPlaneAdapter({
26
28
  pipeline: opts.pipeline,
27
29
  sharedSecret: opts.secret,
30
+ uiCallbackTokenStore: opts.uiCallbackTokenStore,
28
31
  }),
29
32
  },
30
33
  ];
@@ -69,6 +72,7 @@ export function createStaticAdaptersFromDetected(opts) {
69
72
  outbox: opts.outbox,
70
73
  secret: detected.secret,
71
74
  config: opts.config,
75
+ uiCallbackTokenStore: opts.uiCallbackTokenStore,
72
76
  }));
73
77
  }
74
78
  return adapters;
@@ -1,10 +1,11 @@
1
- import { ControlPlaneCommandPipeline, ControlPlaneOutbox, type ControlPlaneSignalObserver, type ReloadableGenerationIdentity, TelegramControlPlaneAdapter } from "@femtomc/mu-control-plane";
1
+ import { ControlPlaneCommandPipeline, ControlPlaneOutbox, type ControlPlaneSignalObserver, type ReloadableGenerationIdentity, TelegramControlPlaneAdapter, UiCallbackTokenStore } from "@femtomc/mu-control-plane";
2
2
  import type { ControlPlaneConfig, TelegramGenerationReloadResult, TelegramGenerationSwapHooks } from "./control_plane_contract.js";
3
3
  export declare class TelegramAdapterGenerationManager {
4
4
  #private;
5
5
  constructor(opts: {
6
6
  pipeline: ControlPlaneCommandPipeline;
7
7
  outbox: ControlPlaneOutbox;
8
+ uiCallbackTokenStore: UiCallbackTokenStore;
8
9
  initialConfig: ControlPlaneConfig;
9
10
  nowMs?: () => number;
10
11
  onOutboxEnqueued?: () => void;
@@ -79,6 +79,7 @@ async function runWithTimeout(opts) {
79
79
  export class TelegramAdapterGenerationManager {
80
80
  #pipeline;
81
81
  #outbox;
82
+ #uiCallbackTokenStore;
82
83
  #nowMs;
83
84
  #onOutboxEnqueued;
84
85
  #signalObserver;
@@ -90,6 +91,7 @@ export class TelegramAdapterGenerationManager {
90
91
  constructor(opts) {
91
92
  this.#pipeline = opts.pipeline;
92
93
  this.#outbox = opts.outbox;
94
+ this.#uiCallbackTokenStore = opts.uiCallbackTokenStore;
93
95
  this.#nowMs = opts.nowMs ?? Date.now;
94
96
  this.#onOutboxEnqueued = opts.onOutboxEnqueued ?? null;
95
97
  this.#signalObserver = opts.signalObserver ?? null;
@@ -115,6 +117,7 @@ export class TelegramAdapterGenerationManager {
115
117
  acceptIngress: opts.acceptIngress,
116
118
  ingressDrainEnabled: opts.ingressDrainEnabled,
117
119
  nowMs: this.#nowMs,
120
+ uiCallbackTokenStore: this.#uiCallbackTokenStore,
118
121
  });
119
122
  }
120
123
  async initialize() {
@@ -13,7 +13,7 @@ export function createServerRequestHandler(deps) {
13
13
  if (request.method === "OPTIONS") {
14
14
  return new Response(null, { status: 204, headers });
15
15
  }
16
- if (path === "/healthz" || path === "/health") {
16
+ if (path === "/healthz") {
17
17
  return new Response("ok", { status: 200, headers });
18
18
  }
19
19
  if (path === "/api/server/shutdown") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.106",
3
+ "version": "26.2.107",
4
4
  "description": "HTTP API server for mu control-plane transport/session plus run/activity scheduling coordination.",
5
5
  "keywords": [
6
6
  "mu",
@@ -30,8 +30,8 @@
30
30
  "start": "bun run dist/cli.js"
31
31
  },
32
32
  "dependencies": {
33
- "@femtomc/mu-agent": "26.2.106",
34
- "@femtomc/mu-control-plane": "26.2.106",
35
- "@femtomc/mu-core": "26.2.106"
33
+ "@femtomc/mu-agent": "26.2.107",
34
+ "@femtomc/mu-control-plane": "26.2.107",
35
+ "@femtomc/mu-core": "26.2.107"
36
36
  }
37
37
  }