@xema/omni-protocol 0.1.58 → 0.1.60

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/index.d.ts CHANGED
@@ -335,13 +335,34 @@ export interface AuthenticationSession {
335
335
  * does not decide for the adapter what a missing one means. The adapter does what its platform
336
336
  * needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.
337
337
  */
338
+ /**
339
+ * Who silenced a device that is present and permitted: the host, by its own Mute, or the station --
340
+ * a slider on the headset, the operating system -- which the host observes and, on a browser,
341
+ * cannot touch. The same word on a `muted` handling leg says whose the silence was.
342
+ */
343
+ export type MutedBy = "host" | "station";
344
+ export declare const MUTED_BY: readonly ["host", "station"];
345
+ /**
346
+ * What pressing Mute does on this host. `stream`: the host stops the audio it sends, and the
347
+ * microphone keeps capturing. `station`: the host mutes the microphone itself at the operating
348
+ * system, which every application on the machine then shares. A browser can only do the first;
349
+ * a native host says which it does. Stated by the host on a softphone login, never inferred
350
+ * from what kind of application it is.
351
+ */
352
+ export type HostMute = "stream" | "station";
353
+ export declare const HOST_MUTES: readonly ["stream", "station"];
338
354
  export type HostAudioInput =
339
- /** Omni has the microphone. `flowing` is false while the hardware or OS says no audio moves through it. */
340
- {
355
+ /** Omni has the microphone. `flowing` is false while no audio moves through it, and then `mutedBy` says who stopped it. */
356
+ ({
341
357
  status: "available";
342
358
  localAudio: MediaStream;
343
- flowing: boolean;
344
- }
359
+ } & ({
360
+ flowing: true;
361
+ mutedBy?: never;
362
+ } | {
363
+ flowing: false;
364
+ mutedBy: MutedBy;
365
+ }))
345
366
  /** Omni does not, and `reason` says which fix the agent needs; `failure` is the words Omni showed them. */
346
367
  | {
347
368
  status: "unavailable";
@@ -356,9 +377,17 @@ export type HostAudioInput =
356
377
  export type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
357
378
  /** Why the host has no speaker: no device, or one that was removed. */
358
379
  export type HostOutputUnavailableReason = "no-device" | "lost";
359
- export type HostAudioOutput = {
380
+ export type HostAudioOutput =
381
+ /** Omni has a speaker. `flowing` is stated only where the host can know it: false while the output is silenced, and then `mutedBy` says who did it. */
382
+ ({
360
383
  status: "available";
384
+ } & ({
385
+ flowing?: true;
386
+ mutedBy?: never;
361
387
  } | {
388
+ flowing: false;
389
+ mutedBy: MutedBy;
390
+ })) | {
362
391
  status: "unavailable";
363
392
  reason: HostOutputUnavailableReason;
364
393
  failure: ProtocolFailure;
@@ -393,6 +422,8 @@ export interface HostGuarantees {
393
422
  }
394
423
  export interface Host {
395
424
  guarantees: HostGuarantees;
425
+ /** What pressing Mute does on this host. Stated on a softphone login, where the host holds the microphone; absent on a desk phone and off voice. */
426
+ mute?: HostMute;
396
427
  report(): HostReport;
397
428
  subscribe(listener: (report: HostReport) => void): Unsubscribe;
398
429
  }
@@ -492,7 +523,6 @@ export interface SharedTaskCapabilities {
492
523
  */
493
524
  export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
494
525
  decline?: Lockable<true>;
495
- mute?: Lockable<true>;
496
526
  hold?: Lockable<true>;
497
527
  /** The agent may end the whole call: everyone leaves and the media ends, the task stays for its wrap-up. */
498
528
  endCall?: Lockable<true>;
@@ -623,6 +653,8 @@ export interface TaskHandlingStep {
623
653
  * provider could not attribute it, which is a different claim and a legitimate one.
624
654
  */
625
655
  by?: UserId;
656
+ /** On a `muted` step, and only there: whose the silence was, as the host reported it. */
657
+ mutedBy?: MutedBy;
626
658
  }
627
659
  /**
628
660
  * How the task ends, and how long after handling the provider allows for it.
@@ -830,7 +862,7 @@ export type TaskOutcome = {
830
862
  failure: ProtocolFailure;
831
863
  };
832
864
  export declare const TASK_COMMAND_NAMES: {
833
- readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "end-call", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
865
+ readonly voice: readonly ["answer", "decline", "call", "hold", "resume", "end-call", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
834
866
  readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
835
867
  readonly email: readonly ["accept", "decline", "complete"];
836
868
  };
@@ -848,9 +880,6 @@ export type VoiceTaskCommand = {
848
880
  | {
849
881
  type: "call";
850
882
  dialId: DialId;
851
- } | {
852
- type: "mute";
853
- muted: boolean;
854
883
  } | {
855
884
  type: "hold";
856
885
  } | {
@@ -1128,7 +1157,7 @@ export interface TeamRoster {
1128
1157
  }
1129
1158
  /** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
1130
1159
  export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
1131
- /** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
1160
+ /** On for everyone, off for everyone, or the agent's own choice. Only `hold` and skills may be `person`. */
1132
1161
  export type TeamPolicySetting = "on" | "off" | "person";
1133
1162
  /** One policy as the lead sees it: the setting, who set it, and `lockedBy` when a level above the team made it theirs to keep. */
1134
1163
  export interface TeamPolicy extends Resolved {
@@ -1222,12 +1251,12 @@ export type OpenMediaResult = {
1222
1251
  };
1223
1252
  /** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
1224
1253
  /**
1225
- * What the team may leave to the person: a capability by its own name -- `hold`, `mute` -- or a
1254
+ * What the team may leave to the person: a capability by its own name -- `hold` -- or a
1226
1255
  * skill by its provider id. The same key as in `Task.capabilities`, because it is the same
1227
1256
  * capability seen at another level. Connecting back and a new call are never the person's; they
1228
1257
  * are the team's, on or off, within what the queue allows.
1229
1258
  */
1230
- export type PreferenceId = "hold" | "mute" | `skill:${string}`;
1259
+ export type PreferenceId = "hold" | `skill:${string}`;
1231
1260
  /**
1232
1261
  * Who stated a value as it stands: a level -- `person` among them -- or `provisioning`, the
1233
1262
  * protocol's own word for "no level has said anything and the provider's default applies".
@@ -1260,8 +1289,8 @@ export type SetPreferenceRequest = {
1260
1289
  inherit: true;
1261
1290
  };
1262
1291
  /**
1263
- * The host's report of a handling leg it performed itself -- a mute, which Omni does rather than
1264
- * the provider -- so the provider's record has an account of it. Keyed by `step` and `at`: the
1292
+ * The host's report of a handling leg it performed itself -- a mute, which is the host's and
1293
+ * never the provider's -- so the provider's record has an account of it. Keyed by `step` and `at`: the
1265
1294
  * same entry is reported when it begins, as often as the host cares to while it runs, and once
1266
1295
  * more with `ended`, when `seconds` is the final duration. The host is the authority for the
1267
1296
  * legs it performs, so `seconds` may say how long so far at any time; the end is stated, never
@@ -1269,9 +1298,16 @@ export type SetPreferenceRequest = {
1269
1298
  */
1270
1299
  export type HandlingReport = {
1271
1300
  taskId: TaskId;
1272
- step: HandlingStep;
1273
1301
  at: IsoTimestamp;
1274
- } & ({
1302
+ } & (
1303
+ /** A muted leg says whose the silence was: the host's own Mute, or the station's slider or system. */
1304
+ {
1305
+ step: "muted";
1306
+ mutedBy: MutedBy;
1307
+ } | {
1308
+ step: Exclude<HandlingStep, "muted">;
1309
+ mutedBy?: never;
1310
+ }) & ({
1275
1311
  ended: true;
1276
1312
  seconds: DurationSeconds;
1277
1313
  } | {
@@ -1443,7 +1479,7 @@ export interface Connection<C extends Channel = Channel> {
1443
1479
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
1444
1480
  /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
1445
1481
  setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
1446
- /** Records a handling leg the host performed. Required of a connection whose tasks may declare `mute`. */
1482
+ /** Records a handling leg the host performed. Required of a softphone login's connection: the host mutes its microphone on any call, and the record is the provider's. */
1447
1483
  recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
1448
1484
  }
1449
1485
  export interface Adapter<C extends Channel = Channel> {
package/dist/index.js CHANGED
@@ -27,6 +27,8 @@ export const IDLE_CAPABILITY_UI = {
27
27
  contacts: "Contacts",
28
28
  };
29
29
  export const MONITOR_MODES = ["monitor", "whisper", "barge"];
30
+ export const MUTED_BY = ["host", "station"];
31
+ export const HOST_MUTES = ["stream", "station"];
30
32
  // ---------------------------------------------------------------------------
31
33
  // Task workspace.
32
34
  // ---------------------------------------------------------------------------
@@ -73,7 +75,7 @@ export const CAPABILITY_SOURCES = ["queue", "ungoverned", "undetermined"];
73
75
  // Task commands.
74
76
  // ---------------------------------------------------------------------------
75
77
  export const TASK_COMMAND_NAMES = {
76
- voice: ["answer", "decline", "call", "mute", "hold", "resume", "end-call",
78
+ voice: ["answer", "decline", "call", "hold", "resume", "end-call",
77
79
  "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"],
78
80
  chat: ["accept", "decline", "pause", "resume", "complete"],
79
81
  email: ["accept", "decline", "complete"],
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type Task, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostGuarantees, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
1
+ import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type Task, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostGuarantees, type HostMute, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
2
2
  import { type ProtocolViolation } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
4
4
  /**
@@ -153,8 +153,11 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
153
153
  * follows the media ending is `completing` or `task-ended`.
154
154
  */
155
155
  export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
156
- /** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
157
- export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host;
156
+ /**
157
+ * A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`.
158
+ * A softphone host states what its Mute does; a desk-phone or conversation host has no microphone and states nothing.
159
+ */
160
+ export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees, mute?: HostMute): Host;
158
161
  /** One provider as the host sees it when freezing the providers a break attempt asks. */
159
162
  export interface BreakCandidate {
160
163
  id: string;
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateHostMute, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -271,6 +271,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
271
271
  violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio",
272
272
  message: adapter.manifest.channel === "voice" ? "a desk-phone login has no audio in the host to report" : `a ${adapter.manifest.channel} connection has no audio for the host to report` });
273
273
  }
274
+ // What the host's Mute does is stated where the host holds a microphone, and nowhere else.
275
+ violations.push(...validateHostMute(context.host.mute, softphone, "context.host.mute"));
274
276
  unsubscribeHost = context.host.subscribe(report => {
275
277
  violations.push(...validateHostReport(report, "context.host"));
276
278
  });
@@ -289,6 +291,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
289
291
  // On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
290
292
  if (softphone)
291
293
  requireMethod(live, "openMedia", "the login is on a softphone");
294
+ // The microphone is the host's, so on a softphone every call can be muted by it, and the
295
+ // record of that leg is the provider's to take; on a desk phone the host holds no microphone.
296
+ if (softphone)
297
+ requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
292
298
  const eventIds = new Set();
293
299
  // The drive waits on events: each waiter is offered every envelope as it lands.
294
300
  const waiters = new Set();
@@ -304,8 +310,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
304
310
  violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
305
311
  if (eventNamesUsers(envelope))
306
312
  requireMethod(live, "describeUsers", "an event publishes a UserId");
307
- if (eventDeclaresMute(envelope))
308
- requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
309
313
  // Cross-event rules apply once the stream has a beginning: the connect snapshot.
310
314
  if (seeded)
311
315
  violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
@@ -328,9 +332,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
328
332
  requireCapabilityMethods(live, current().capabilities);
329
333
  if (publishesUserIds(snapshot))
330
334
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
331
- if (isRecord(snapshot) && Array.isArray(snapshot.tasks) && snapshot.tasks.some(taskDeclaresMute)) {
332
- requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
333
- }
334
335
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
335
336
  // that will not accept one is a connection nothing can be given to.
336
337
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
@@ -369,7 +370,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
369
370
  const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
370
371
  ? first.audio.input.localAudio : undefined;
371
372
  violations.push(...await driveOneCall({
372
- connection: live, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
373
+ connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
373
374
  timeoutMs: options.driveTimeoutMs ?? 5000,
374
375
  }));
375
376
  }
@@ -445,18 +446,6 @@ const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHisto
445
446
  (isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
446
447
  isRecord(task.assisting) ||
447
448
  isRecord(task.monitoring));
448
- /** Mute is the leg the host performs, so a task that allows it obliges the provider to take the record. */
449
- const taskDeclaresMute = (task) => isRecord(task) && isRecord(task.capabilities) && task.capabilities.mute !== undefined;
450
- function eventDeclaresMute(envelope) {
451
- const event = isRecord(envelope) ? envelope.event : undefined;
452
- if (!isRecord(event))
453
- return false;
454
- if (event.type === "task-offered" || event.type === "task-updated")
455
- return taskDeclaresMute(event.task);
456
- if (event.type === "snapshot" && isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks))
457
- return event.snapshot.tasks.some(taskDeclaresMute);
458
- return false;
459
- }
460
449
  /** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
461
450
  function eventNamesUsers(envelope) {
462
451
  const event = isRecord(envelope) ? envelope.event : undefined;
@@ -1034,6 +1023,49 @@ async function driveOneCall(drive) {
1034
1023
  else if (isRecord(opened))
1035
1024
  refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
1036
1025
  }
1026
+ // 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
1027
+ // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1028
+ // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1029
+ let mutedLeg;
1030
+ if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
1031
+ const at = new Date().toISOString();
1032
+ const report = async (body) => {
1033
+ // The drive holds its own report to the contract before it crosses, as a host must.
1034
+ const leg = { taskId, step: "muted", at, mutedBy: "host", ...body };
1035
+ const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1036
+ found.push(...own);
1037
+ if (own.length > 0)
1038
+ return;
1039
+ let answer;
1040
+ try {
1041
+ answer = await drive.connection.recordStep(leg);
1042
+ }
1043
+ catch (error) {
1044
+ refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
1045
+ return;
1046
+ }
1047
+ found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
1048
+ if (isRecord(answer) && answer.status === "failed") {
1049
+ refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
1050
+ }
1051
+ };
1052
+ const setMuted = (muted) => {
1053
+ try {
1054
+ session.setMuted(muted);
1055
+ }
1056
+ catch {
1057
+ refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
1058
+ }
1059
+ };
1060
+ const began = Date.now();
1061
+ setMuted(true);
1062
+ await report({});
1063
+ // A leg's duration is whole seconds and a leg shorter than one cannot be stated, so the mute holds for one.
1064
+ await new Promise(resolve => setTimeout(resolve, 1000));
1065
+ setMuted(false);
1066
+ await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
1067
+ mutedLeg = { at, task: latestTask() };
1068
+ }
1037
1069
  // 4. Hold and resume, where offered.
1038
1070
  if (latestTask().phase === "in-progress" && offers("hold")) {
1039
1071
  if (await send({ type: drive.channel === "chat" ? "pause" : "hold" }) !== undefined) {
@@ -1053,7 +1085,21 @@ async function driveOneCall(drive) {
1053
1085
  }, cursor);
1054
1086
  if (mediaEnded !== undefined)
1055
1087
  cursor = mediaEnded.at;
1056
- await updated(t => t.phase === "completing", "the task completing after its media ended");
1088
+ if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1089
+ // The other direction of step 4: the same control, still declared, on a call that is over.
1090
+ // A host holds it back (command.phase.handling), and an adapter that receives it anyway
1091
+ // must refuse it -- so the drive sends it past the validator and expects failed.
1092
+ let answer;
1093
+ try {
1094
+ answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
1095
+ }
1096
+ catch (error) {
1097
+ refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
1098
+ }
1099
+ if (isRecord(answer) && answer.status !== "failed") {
1100
+ refuse("drive.command.handling", "drive.command.hold", `the provider applied hold on a completing task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
1101
+ }
1102
+ }
1057
1103
  }
1058
1104
  }
1059
1105
  if (typeof session?.close === "function") {
@@ -1080,6 +1126,20 @@ async function driveOneCall(drive) {
1080
1126
  else if (latestTask().phase === "completing") {
1081
1127
  await ended();
1082
1128
  }
1129
+ // The record kept by the provider has the leg the host reported, wherever the provider restated it.
1130
+ if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
1131
+ const history = latestTask().handlingHistory;
1132
+ if (isRecord(history) && Array.isArray(history.steps)) {
1133
+ const { at } = mutedLeg;
1134
+ const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
1135
+ if (leg === undefined) {
1136
+ refuse("drive.recordStep.history", "drive.recordStep", `the provider restated the task's record after the host reported a muted leg at ${at}, and the leg is not in it`);
1137
+ }
1138
+ else if (leg.mutedBy !== "host") {
1139
+ refuse("drive.recordStep.history", "drive.recordStep", `the record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host, and the record keeps the host's word`);
1140
+ }
1141
+ }
1142
+ }
1083
1143
  return found;
1084
1144
  }
1085
1145
  // A request goes not-requested -> awaiting-decision | granted; a commit goes granted ->
@@ -1159,9 +1219,12 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
1159
1219
  envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
1160
1220
  assertNoViolations(found, "The media follows the task");
1161
1221
  }
1162
- /** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
1163
- export function stillHost(report = { online: true }, guarantees = {}) {
1164
- return { guarantees, report: () => report, subscribe: () => () => undefined };
1222
+ /**
1223
+ * A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`.
1224
+ * A softphone host states what its Mute does; a desk-phone or conversation host has no microphone and states nothing.
1225
+ */
1226
+ export function stillHost(report = { online: true }, guarantees = {}, mute) {
1227
+ return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
1165
1228
  }
1166
1229
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
1167
1230
  /**
@@ -51,6 +51,12 @@ export interface ReaderContext {
51
51
  export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
52
52
  export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
53
53
  export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
54
+ /**
55
+ * What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
56
+ * phone the microphone is the phone's, and off voice there is none. Neither the value nor its
57
+ * absence is inferred from what kind of application the host is.
58
+ */
59
+ export declare function validateHostMute(mute: unknown, softphone: boolean, path?: string): ProtocolViolation[];
54
60
  /**
55
61
  * What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
56
62
  * promise withheld is an absent key, never a false one, exactly as a capability is.
@@ -96,7 +96,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
96
96
  /** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
97
97
  const TASK_CAPABILITIES = {
98
98
  voice: membersOf({
99
- browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
99
+ browsers: true, dispositions: true, custom: true, decline: true, hold: true,
100
100
  endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
101
101
  }),
102
102
  chat: membersOf({ browsers: true, dispositions: true, custom: true, decline: true, hold: true }),
@@ -574,7 +574,7 @@ const POLICY_SETTINGS = membersOf({ on: true, off: true, person: true });
574
574
  const POLICY_KEYS = new Set([
575
575
  ...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
576
576
  ].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
577
- const PERSON_SETTABLE = /^(hold|mute|skill:.+)$/;
577
+ const PERSON_SETTABLE = /^(hold|skill:.+)$/;
578
578
  const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
579
579
  /** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
580
580
  const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
@@ -751,6 +751,12 @@ function validateHandlingHistory(value, path, into) {
751
751
  // Omitted while a leg is still running. Nought is a claim that it took no time.
752
752
  into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.handlingHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
753
753
  }
754
+ // A muted entry carries whose the silence was, as the host reported it; no other step has it.
755
+ if (entry.step === "muted")
756
+ into.oneOf(entry.mutedBy, MUTED_BY, "task.handlingHistory.mutedBy", `${at}.mutedBy`);
757
+ else if (HANDLING_STEPS.includes(entry.step)) {
758
+ into.require(entry.mutedBy === undefined, "task.handlingHistory.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
759
+ }
754
760
  if (entry.by !== undefined) {
755
761
  into.require(isUserId(entry.by), "task.handlingHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
756
762
  // On `queued` nobody takes part, so there is nothing to name.
@@ -992,8 +998,13 @@ function validateTaskInto(task, context, path, into) {
992
998
  for (const [name, declared] of Object.entries(capabilities)) {
993
999
  if (declared === undefined)
994
1000
  continue;
995
- if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
1001
+ if (!allowed.includes(name)) {
1002
+ // A name another channel owns is a channel error; one no channel owns is not a capability at
1003
+ // all -- mute among them, since the microphone is the host's and no provider declares it.
1004
+ const known = Object.values(TASK_CAPABILITIES).some(names => names.includes(name));
1005
+ into.add(known ? "task.capability.channel" : "task.capability.unknown", `${path}.capabilities.${name}`, known ? `a ${context.channel} task may not declare ${name}` : `${name} is not a capability a provider declares on any channel`);
996
1006
  continue;
1007
+ }
997
1008
  // A control that dials needs the manifest to have said how a dial ends, or its outcome has no words.
998
1009
  if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
999
1010
  into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
@@ -1538,7 +1549,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1538
1549
  // Results. A result crosses the same boundary a snapshot does, from an adapter that may be
1539
1550
  // compiled against another version, and Omni shows the agent what it says.
1540
1551
  // ---------------------------------------------------------------------------
1541
- const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
1552
+ const PREFERENCE_ID = /^(hold|skill:.+)$/;
1542
1553
  /** The choices left to the person, each with where it stands. */
1543
1554
  function validatePreferencesInto(value, path, into, levels) {
1544
1555
  if (!Array.isArray(value)) {
@@ -1552,7 +1563,7 @@ function validatePreferencesInto(value, path, into, levels) {
1552
1563
  into.add("preference.shape", at, "each preference must be an object");
1553
1564
  return;
1554
1565
  }
1555
- if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold, mute, or skill:<id>: nothing else is the person's to set")) {
1566
+ if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold or skill:<id>: nothing else is the person's to set")) {
1556
1567
  if (seen.has(preference.id))
1557
1568
  into.add("preference.unique", `${at}.id`, `duplicate preference: ${preference.id}`);
1558
1569
  seen.add(preference.id);
@@ -1579,7 +1590,7 @@ function validateTeamPoliciesInto(value, path, into, levels) {
1579
1590
  continue;
1580
1591
  }
1581
1592
  if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
1582
- into.require(policy.setting !== "person" || PERSON_SETTABLE.test(key), "team.policy.person", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
1593
+ into.require(policy.setting !== "person" || PERSON_SETTABLE.test(key), "team.policy.person", `${at}.setting`, `${key} is the team's, on or off; only hold and skills may be left to the person`);
1583
1594
  }
1584
1595
  validateResolvedInto(policy, "team.policy", at, levels, into);
1585
1596
  into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
@@ -1621,6 +1632,23 @@ function validateUnavailable(value, rule, path, into) {
1621
1632
  * hands the adapter.
1622
1633
  */
1623
1634
  const HOST_GUARANTEES = membersOf({ browserUrlVisibility: true, personConsent: true });
1635
+ const MUTED_BY = membersOf({ host: true, station: true });
1636
+ const HOST_MUTES = membersOf({ stream: true, station: true });
1637
+ /**
1638
+ * What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
1639
+ * phone the microphone is the phone's, and off voice there is none. Neither the value nor its
1640
+ * absence is inferred from what kind of application the host is.
1641
+ */
1642
+ export function validateHostMute(mute, softphone, path = "host.mute") {
1643
+ const into = new Collector();
1644
+ if (mute === undefined) {
1645
+ into.require(!softphone, "host.mute.required", path, "a softphone login's host states what its Mute does: stream or station");
1646
+ }
1647
+ else if (into.require(softphone, "host.mute.unexpected", path, "only a softphone login's host holds a microphone to mute; a desk phone's is the phone's, and a conversation has none")) {
1648
+ into.oneOf(mute, HOST_MUTES, "host.mute", path);
1649
+ }
1650
+ return into.violations;
1651
+ }
1624
1652
  /**
1625
1653
  * What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
1626
1654
  * promise withheld is an absent key, never a false one, exactly as a capability is.
@@ -1654,6 +1682,12 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
1654
1682
  into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
1655
1683
  into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
1656
1684
  into.timestamp(report.at, "handlingReport.at", `${path}.at`);
1685
+ // A muted leg says whose the silence was; no other leg has anyone to name for it.
1686
+ if (report.step === "muted")
1687
+ into.oneOf(report.mutedBy, MUTED_BY, "handlingReport.mutedBy", `${path}.mutedBy`);
1688
+ else if (HANDLING_STEPS.includes(report.step)) {
1689
+ into.require(report.mutedBy === undefined, "handlingReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
1690
+ }
1657
1691
  if (report.seconds !== undefined) {
1658
1692
  into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "handlingReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
1659
1693
  }
@@ -1669,6 +1703,15 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
1669
1703
  }
1670
1704
  return into.violations;
1671
1705
  }
1706
+ /** A silenced device says who silenced it; one that flows, or whose flow the host cannot know, says nothing. */
1707
+ function validateMutedBy(device, rule, path, into) {
1708
+ if (device.flowing === false) {
1709
+ into.oneOf(device.mutedBy, MUTED_BY, `${rule}.mutedBy`, `${path}.mutedBy`);
1710
+ }
1711
+ else {
1712
+ into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
1713
+ }
1714
+ }
1672
1715
  export function validateHostReport(report, path = "host") {
1673
1716
  const into = new Collector();
1674
1717
  if (!isPlainObject(report)) {
@@ -1690,6 +1733,7 @@ export function validateHostReport(report, path = "host") {
1690
1733
  else if (input.status === "available") {
1691
1734
  into.require(typeof input.localAudio === "object" && input.localAudio !== null, "host.audio.input.localAudio", `${at}.localAudio`, "a ready input carries the captured microphone");
1692
1735
  into.require(typeof input.flowing === "boolean", "host.audio.input.flowing", `${at}.flowing`, "a ready input says whether audio is flowing through it");
1736
+ validateMutedBy(input, "host.audio.input", at, into);
1693
1737
  into.require(input.failure === undefined, "host.audio.input.failure.unexpected", `${at}.failure`, "a ready input carries no failure");
1694
1738
  into.require(input.reason === undefined, "host.audio.input.reason.unexpected", `${at}.reason`, "a ready input has no reason to be unavailable");
1695
1739
  }
@@ -1698,6 +1742,7 @@ export function validateHostReport(report, path = "host") {
1698
1742
  validateUnavailable(input, "host.audio.input", at, into);
1699
1743
  into.require(input.localAudio === undefined, "host.audio.input.localAudio.unexpected", `${at}.localAudio`, "an unavailable input carries no microphone");
1700
1744
  into.require(input.flowing === undefined, "host.audio.input.flowing.unexpected", `${at}.flowing`, "an unavailable input has nothing to flow");
1745
+ into.require(input.mutedBy === undefined, "host.audio.input.mutedBy.unexpected", `${at}.mutedBy`, "an unavailable input was not silenced; it is absent");
1701
1746
  }
1702
1747
  else {
1703
1748
  into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${String(input.status)}`);
@@ -1708,12 +1753,19 @@ export function validateHostReport(report, path = "host") {
1708
1753
  into.add("host.audio.output.shape", out, "audio carries its output");
1709
1754
  }
1710
1755
  else if (output.status === "available") {
1756
+ // Whether the speaker is silenced is stated only where the host can know it: a browser mostly cannot, and omits it.
1757
+ if (output.flowing !== undefined) {
1758
+ into.require(typeof output.flowing === "boolean", "host.audio.output.flowing", `${out}.flowing`, "flowing says whether audio reaches the speaker, when the host can know");
1759
+ }
1760
+ validateMutedBy(output, "host.audio.output", out, into);
1711
1761
  into.require(output.failure === undefined, "host.audio.output.failure.unexpected", `${out}.failure`, "a ready output carries no failure");
1712
1762
  into.require(output.reason === undefined, "host.audio.output.reason.unexpected", `${out}.reason`, "a ready output has no reason to be unavailable");
1713
1763
  }
1714
1764
  else if (output.status === "unavailable") {
1715
1765
  into.oneOf(output.reason, HOST_OUTPUT_REASONS, "host.audio.output.reason", `${out}.reason`);
1716
1766
  validateUnavailable(output, "host.audio.output", out, into);
1767
+ into.require(output.flowing === undefined, "host.audio.output.flowing.unexpected", `${out}.flowing`, "an unavailable output has nothing to flow");
1768
+ into.require(output.mutedBy === undefined, "host.audio.output.mutedBy.unexpected", `${out}.mutedBy`, "an unavailable output was not silenced; it is absent");
1717
1769
  }
1718
1770
  else {
1719
1771
  into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${String(output.status)}`);
@@ -1754,9 +1806,6 @@ export function validateTaskCommand(command, task, path = "command") {
1754
1806
  }
1755
1807
  const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
1756
1808
  switch (type) {
1757
- case "mute":
1758
- into.require(typeof command.muted === "boolean", "command.mute.muted", `${path}.muted`, "mute says whether the agent is muted");
1759
- break;
1760
1809
  case "call":
1761
1810
  dial("command.call.dialId");
1762
1811
  break;
@@ -1831,6 +1880,10 @@ export function validateTaskCommand(command, task, path = "command") {
1831
1880
  return true;
1832
1881
  };
1833
1882
  const inPhase = (rule, ...phases) => into.require(phases.includes(String(task.phase)), rule, path, `${String(type)} belongs to ${phases.join(" or ")}, and the task is ${String(task.phase)}`);
1883
+ // A control on the contact acts on a contact being handled. Before in-progress there is nothing
1884
+ // to act on yet, and in completing the handling has ended: a call with nobody on it, a
1885
+ // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
1886
+ const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
1834
1887
  const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
1835
1888
  switch (type) {
1836
1889
  case "answer":
@@ -1844,25 +1897,26 @@ export function validateTaskCommand(command, task, path = "command") {
1844
1897
  case "call":
1845
1898
  inPhase("command.phase.preview", "preview");
1846
1899
  break;
1847
- case "mute":
1848
- offered("mute");
1849
- break;
1850
1900
  case "hold":
1851
1901
  case "resume":
1852
1902
  case "pause":
1853
1903
  offered("hold");
1904
+ handling();
1854
1905
  break;
1855
1906
  case "end-call":
1856
1907
  offered("endCall");
1908
+ handling();
1857
1909
  break;
1858
1910
  case "recording":
1859
1911
  offered("recording");
1912
+ handling();
1860
1913
  break;
1861
1914
  case "connect-back":
1862
1915
  offered("connectBack");
1863
1916
  inPhase("command.phase.completing", "completing");
1864
1917
  break;
1865
1918
  case "transfer":
1919
+ handling();
1866
1920
  if (command.action === "cold")
1867
1921
  offered("coldTransfer");
1868
1922
  else if (command.action === "warm")
@@ -1872,6 +1926,7 @@ export function validateTaskCommand(command, task, path = "command") {
1872
1926
  }
1873
1927
  break;
1874
1928
  case "lead-assist":
1929
+ handling();
1875
1930
  if (command.action === "request" || command.action === "cancel") {
1876
1931
  offered("leadAssist");
1877
1932
  if (command.action === "cancel") {
@@ -1883,6 +1938,7 @@ export function validateTaskCommand(command, task, path = "command") {
1883
1938
  }
1884
1939
  break;
1885
1940
  case "conference":
1941
+ handling();
1886
1942
  if (offered("conference") && command.action === "remove") {
1887
1943
  const others = onCall.filter(entry => entry.role !== "agent");
1888
1944
  if (command.party === true) {
package/guide.md CHANGED
@@ -271,12 +271,16 @@ type AuthenticationFailure = {
271
271
  type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
272
272
  type HostOutputUnavailableReason = "no-device" | "lost";
273
273
 
274
+ type MutedBy = "host" | "station";
275
+
276
+ type HostMute = "stream" | "station";
277
+
274
278
  type HostAudioInput =
275
- | { status: "available"; localAudio: MediaStream; flowing: boolean }
279
+ | ({ status: "available"; localAudio: MediaStream } & ({ flowing: true; mutedBy?: never } | { flowing: false; mutedBy: MutedBy }))
276
280
  | { status: "unavailable"; reason: HostAudioUnavailableReason; failure: ProtocolFailure };
277
281
 
278
282
  type HostAudioOutput =
279
- | { status: "available" }
283
+ | ({ status: "available" } & ({ flowing?: true; mutedBy?: never } | { flowing: false; mutedBy: MutedBy }))
280
284
  | { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
281
285
 
282
286
  type UrlVisibility = "full" | "domain" | "hidden";
@@ -296,6 +300,7 @@ type HostGuarantees = {
296
300
 
297
301
  type Host = {
298
302
  guarantees: HostGuarantees;
303
+ mute?: HostMute;
299
304
  report(): HostReport;
300
305
  subscribe(listener: (report: HostReport) => void): Unsubscribe;
301
306
  };
@@ -361,7 +366,7 @@ type AuthenticationSession = {
361
366
  ### Provider state
362
367
 
363
368
  ```ts
364
- type PreferenceId = "hold" | "mute" | `skill:${string}`;
369
+ type PreferenceId = "hold" | `skill:${string}`;
365
370
 
366
371
  type SetBy = Level | "provider";
367
372
 
@@ -381,7 +386,10 @@ type SetPreferenceRequest =
381
386
  | { id: PreferenceId; enabled: boolean }
382
387
  | { id: PreferenceId; inherit: true };
383
388
 
384
- type HandlingReport = { taskId: TaskId; step: HandlingStep; at: IsoTimestamp } & (
389
+ type HandlingReport = { taskId: TaskId; at: IsoTimestamp } & (
390
+ | { step: "muted"; mutedBy: MutedBy }
391
+ | { step: Exclude<HandlingStep, "muted">; mutedBy?: never }
392
+ ) & (
385
393
  | { ended: true; seconds: DurationSeconds }
386
394
  | { ended?: never; seconds?: DurationSeconds }
387
395
  );
@@ -484,7 +492,6 @@ type TaskCapabilities<C extends Channel = Channel> =
484
492
  C extends "voice"
485
493
  ? SharedTaskCapabilities & {
486
494
  decline?: Lockable<true>;
487
- mute?: Lockable<true>;
488
495
  hold?: Lockable<true>;
489
496
  endCall?: Lockable<true>;
490
497
  connectBack?: Lockable<true>;
@@ -592,6 +599,7 @@ type TaskHandlingStep = {
592
599
  destinationId?: string;
593
600
  seconds?: DurationSeconds;
594
601
  by?: UserId;
602
+ mutedBy?: MutedBy;
595
603
  };
596
604
 
597
605
  type TaskCompletion =
@@ -692,7 +700,6 @@ const TASK_COMMAND_NAMES = {
692
700
  "answer",
693
701
  "decline",
694
702
  "call",
695
- "mute",
696
703
  "hold",
697
704
  "resume",
698
705
  "end-call",
@@ -716,7 +723,6 @@ type VoiceTaskCommand =
716
723
  | { type: "answer" }
717
724
  | { type: "decline" }
718
725
  | { type: "call"; dialId: DialId }
719
- | { type: "mute"; muted: boolean }
720
726
  | { type: "hold" }
721
727
  | { type: "resume" }
722
728
  | { type: "end-call" }
@@ -767,7 +773,7 @@ type TaskCommandResult =
767
773
  ### Who decides what an agent may do
768
774
 
769
775
  An agent desk has two managers, not one. **The queue** — a process, a work type — is owned by a
770
- process manager and **allows** a set of capabilities: hold, mute, connect back, new call, conference,
776
+ process manager and **allows** a set of capabilities: hold, connect back, new call, conference,
771
777
  whether the number is visible, the actions it offers, the skills it needs. **The people** are
772
778
  managed through the organisation's structure — a team, a location, the organisation itself, in
773
779
  whatever combination the structure defines for a person — and **decide** per capability within
@@ -801,8 +807,8 @@ against them, not only the sign-in. What the wire carries is the resolution:
801
807
  **On a task, a control the queue could allow may stand locked in its place.** `Task.capabilities`
802
808
  is the effective set. What the queue does not allow is absent and nothing is shown. What the queue
803
809
  allows and a level above the person locked is present as `{ lockedBy, reason? }` where the control's
804
- value would be — `mute: { lockedBy: "team", reason: "Nobody on this team mutes" }` — and Omni
805
- renders that control disabled, saying who decided, so an agent who cannot press Mute knows whether
810
+ value would be — `recording: { lockedBy: "team", reason: "Nobody on this team records" }` — and Omni
811
+ renders that control disabled, saying who decided, so an agent who cannot press Record knows whether
806
812
  to ask their lead or their site. `lockedBy` is the discriminant: a value that carries it is the
807
813
  lock, so nothing that can be locked — a directory, a number — may carry that key itself. A
808
814
  contact's number and email are the same, since each identifies a person: where the queue says
@@ -813,14 +819,14 @@ controls — is content, and is never locked.
813
819
 
814
820
  **A lead sets the team's policy from their roster.** A login that declares
815
821
  `capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
816
- with `on`, `off`, or `person`, for any task control, `dial`, or a skill — and only `hold`, `mute`
822
+ with `on`, `off`, or `person`, for any task control, `dial`, or a skill — and only `hold`
817
823
  and skills may be `person`; connect back and new call are the team's, on or off, within what the queue
818
824
  allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
819
825
  `lockedBy` where a level above the team made it theirs to keep, which the lead sees and cannot
820
826
  change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
821
827
 
822
828
  **What the team left to the person is the person's, and the provider keeps it.** The login's
823
- `capabilities.preferences` lists every preference the person may hold — `hold`, `mute`, a skill —
829
+ `capabilities.preferences` lists every preference the person may hold — `hold`, a skill —
824
830
  with where it stands and who set it: `setBy: "team"` while they inherit the team's default,
825
831
  `"person"` once they have set their own, `"provider"` where no level has said anything. Nothing
826
832
  is hidden for want of a row, and a preference a level above has since locked is listed with
@@ -831,8 +837,10 @@ inherit: true }` to give it up and inherit again — answered `applied` and repu
831
837
  person's preference from their own screen, which arrives the same way. A preference is keyed by
832
838
  the capability's own name because it is the same capability at another level: effective in
833
839
  `Task.capabilities`, set for the team in `policies`, left to the person in `preferences`. The
834
- command `mute` acts on one call; the preference `mute` says whether the person wants the control
835
- at all, and a host renders it in its settings, never as the button on a call.
840
+ command `hold` acts on one call; the preference `hold` says whether the person wants the control
841
+ at all, and a host renders it in its settings, never as the button on a call. Mute is in none of
842
+ these ladders: the microphone is the host's, and nobody on the provider's side allows, locks or
843
+ keeps a choice about it. See **The station is the host's**.
836
844
 
837
845
  ## Breaks
838
846
 
@@ -2003,7 +2011,7 @@ surface in one place, and what obliges an adapter to implement each one.
2003
2011
  | `executeTeamLeadAssist(command)` | The login declares `capabilities.team.leadAssistControl`. |
2004
2012
  | `executeTeamMonitor(command)` | The login declares `capabilities.team.monitorControl`. |
2005
2013
  | `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
2006
- | `recordStep(report)` | A task declares `mute`: the host performs that leg, and the provider's record has to have somewhere to take it. See **The host records what it performs**. |
2014
+ | `recordStep(report)` | The manifest lists `softphone` among its `phones`. On a softphone the host mutes its own microphone on any call, and the provider's record has to have somewhere to take that leg; a desk phone's microphone is the phone's. See **The host records what it performs**. |
2007
2015
  | `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
2008
2016
  | `openMedia(request)` | The manifest lists `softphone` among its `phones`. On a softphone the call's audio lands in Omni, so the adapter has to open it; a platform of desk phones alone never does. |
2009
2017
 
@@ -2187,7 +2195,7 @@ into a new session.
2187
2195
  `task-offered` introduces a new task and does not imply acceptance.
2188
2196
 
2189
2197
  `Task<C>` is channel-discriminated. For example, `Task<"email">` accepts
2190
- `browsers` and `dispositions`, but rejects voice-only controls such as `mute` and `hold` at compile
2198
+ `browsers` and `dispositions`, but rejects voice-only controls such as `hold` and `endCall` at compile
2191
2199
  time. Runtime conformance checks also require the task channel to match its provider manifest.
2192
2200
 
2193
2201
  | Field | Contract |
@@ -2464,9 +2472,11 @@ history — until the task's media ends, and a hold neither pauses nor resets it
2464
2472
  duration is the `held` entry's `seconds`, and a desk that restarts its counter on resume is
2465
2473
  counting the wrong thing.
2466
2474
 
2467
- `muted` is there because Omni performs the mute rather than the provider — see **Where a command
2468
- executes** — so without a step the provider, which alone keeps the task's record, would have no
2469
- account of a period the agent could not be heard.
2475
+ `muted` is there because the mute is the host's and never the provider's — see **The station is
2476
+ the host's** — so without a step the provider, which alone keeps the task's record, would have no
2477
+ account of a period the agent could not be heard. Each `muted` entry carries `mutedBy`, as the host
2478
+ reported it: `host` for the agent's own Mute, `station` for a headset or system mute the host
2479
+ observed. No other step has it (`task.handlingHistory.mutedBy`, `.mutedBy.unexpected`).
2470
2480
 
2471
2481
  **A step that dialled says which dial and where.** `transferred`, `conferenced` and `unanswered`
2472
2482
  each end a dial, so the entry carries the `destinationId` it went to and, where a host placed it, the
@@ -2522,8 +2532,8 @@ added here as a need is shown, not invented ahead of one.
2522
2532
 
2523
2533
  #### The host records what it performs
2524
2534
 
2525
- `muted` is the one leg Omni performs rather than the provider, and a record kept by the provider
2526
- would have a hole exactly there. So the host reports it, through `recordStep`, and the provider
2535
+ `muted` is the one leg the host performs rather than the provider, and a record kept by the
2536
+ provider would have a hole exactly there. So the host reports it, through `recordStep`, and the provider
2527
2537
  writes it into its record as it writes every other leg:
2528
2538
 
2529
2539
  ```ts
@@ -2531,9 +2541,9 @@ declare const connection: Connection<"voice">;
2531
2541
  declare const taskId: TaskId;
2532
2542
  declare const at: IsoTimestamp;
2533
2543
 
2534
- void connection.recordStep?.({ taskId, step: "muted", at }); // the moment the agent mutes
2535
- void connection.recordStep?.({ taskId, step: "muted", at, seconds: 15 }); // still running, if the host chooses to say
2536
- void connection.recordStep?.({ taskId, step: "muted", at, seconds: 42, ended: true }); // the moment they unmute
2544
+ void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
2545
+ void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
2546
+ void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
2537
2547
  ```
2538
2548
 
2539
2549
  The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
@@ -2546,7 +2556,11 @@ leg, when it began and when it ended, and `validateHandlingReport(report, path,
2546
2556
  a running one it was never asked for (`handlingReport.running.unexpected`). What a provider that
2547
2557
  did ask for them forwards upstream, and how often, is its own business. The step appears in
2548
2558
  `handlingHistory` when the *provider* publishes it: Omni never writes the record itself.
2549
- `recordStep` is required of a connection whose tasks declare `mute`, and answers `recorded`.
2559
+ `recordStep` is required of every softphone login's connection, since every call on a softphone
2560
+ can be muted by the host, and answers `recorded`. A `muted` report says whose the silence was,
2561
+ `mutedBy: "host"` or `"station"`, and no other report has the word (`handlingReport.mutedBy`,
2562
+ `.mutedBy.unexpected`). On a desk phone the microphone is the phone's:
2563
+ the host mutes nothing and records nothing.
2550
2564
 
2551
2565
  **A host-performed leg still open when the task's media ends, or the task ends, is ended by the
2552
2566
  host at that instant** — `ended: true`, `seconds` to the end, the same `at` — since a provider
@@ -2762,12 +2776,16 @@ const taskCapabilities = {
2762
2776
  } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "browsers">;
2763
2777
  ```
2764
2778
 
2779
+ A capability says the control may be offered; the task's phase says whether there is anything to use
2780
+ it on. Every control below that acts on the call or the conversation -- everything but `decline`,
2781
+ `connectBack` and `dispositions` -- is offered only while the task is `in-progress` or `paused`.
2782
+ See **Which commands need a capability**.
2783
+
2765
2784
  ### Voice capabilities
2766
2785
 
2767
2786
  | Capability | Omni UI | Contract |
2768
2787
  | --- | --- | --- |
2769
2788
  | `decline` | Pending-task button: Decline | The provider can decline a pending voice offer. Omni shows it only when provisioning also permits declining. |
2770
- | `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
2771
2789
  | `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
2772
2790
  | `endCall` | Primary button: End call | Omni may end the whole call: everyone leaves, the media ends, and the task stays for its wrap-up. See **Ending a call, and removing one person from it**. |
2773
2791
  | `connectBack` | Completing-task button: Connect back | Omni may have the provider connect the agent back to the task's party while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Connecting back during completion**. |
@@ -3782,7 +3800,9 @@ what failed, and reports. It never decides for the adapter what a missing microp
3782
3800
  **The host also guarantees, and a promise the provider cannot see is not one it can rely on.**
3783
3801
  Two of this contract's obligations fall on the host rather than the provider — honouring a task
3784
3802
  browser's `urlVisibility`, and taking a `consent` offer only on the person's own press — and more
3785
- than one desk speaks this contract. So `ConnectContext.host.guarantees` says which promises the
3803
+ than one desk speaks this contract: a browser and a native application differ in what they can
3804
+ reach, and a provider never branches on which it is talking to, only on what it has declared —
3805
+ here, and in `host.mute` (see **The station is the host's**). So `ConnectContext.host.guarantees` says which promises the
3786
3806
  connected host makes, declared once per connection, presence being the guarantee exactly as it is
3787
3807
  the permission everywhere else:
3788
3808
 
@@ -3798,8 +3818,8 @@ refuses a false one, as it refuses a name this contract does not list.
3798
3818
  | --- | --- |
3799
3819
  | `online` | Whether the host has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the host does — so `false` is a reason not to go ready and `true` is not a reason to. |
3800
3820
  | `audio` | Present on a voice connection, absent where there is no audio. |
3801
- | `audio.input` | `available` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
3802
- | `audio.output` | `available`, or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
3821
+ | `audio.input` | `available` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while no audio moves through it, when `mutedBy` says who stopped it: `host`, the host's own Mute on a host that mutes the station, or `station`, a slider on the headset or the operating system, which the host observed and did not do. An adapter treats `host` as the agent's act on a call and `station` as a condition of the station. `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
3822
+ | `audio.output` | `available`, with `flowing` where the host can know whether audio reaches the speaker — a browser mostly cannot, and omits it; a native host reads the endpoint — false while it is silenced, when `mutedBy` says who did it, as on input. Or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
3803
3823
 
3804
3824
  Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
3805
3825
  a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
@@ -3811,7 +3831,8 @@ The host's own obligations here — asking at connect, never publishing `not-ask
3811
3831
  publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
3812
3832
  other side: it validates the shape of whatever host a test hands the adapter, requires `audio` on
3813
3833
  a softphone login and none elsewhere -- not on a desk phone, not off voice
3814
- (`context.host.audio.required` / `.unexpected`) -- and refuses a voice adapter that never asked the
3834
+ (`context.host.audio.required` / `.unexpected`), holds `host.mute` to the same line (`host.mute.required`
3835
+ / `.unexpected`, `host.mute` for a third word) -- and refuses a voice adapter that never asked the
3815
3836
  host anything (`connection.host.consulted`).
3816
3837
 
3817
3838
  ### Capacity around setup
@@ -3952,8 +3973,8 @@ unions are declared under **Shapes**.
3952
3973
 
3953
3974
  **A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot converge
3954
3975
  with a stale view: a flip against a state the provider has already changed turns something on and
3955
- then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
3956
- boolean. `hold` and `resume`, `pause` and `resume` need no flag, being pairs rather than toggles.
3976
+ then off again. A custom `toggle` control therefore carries its own boolean. `hold` and `resume`,
3977
+ `pause` and `resume` need no flag, being pairs rather than toggles.
3957
3978
 
3958
3979
  **`complete` sends a disposition only where one was published.** `disposition` is a
3959
3980
  `DispositionCode.id` from the task's own `dispositions` capability, and `notes` obeys that
@@ -3961,14 +3982,10 @@ capability's `notes` setting. A task publishing no codes still receives `complet
3961
3982
 
3962
3983
  ### Where a command executes
3963
3984
 
3964
- Every command reaches the provider through `execute`, with no branch at the call site. What differs
3965
- is what the provider is being asked for: to **perform** the command, or to **record** that Omni
3966
- already did.
3967
-
3968
- | Command | The provider's part |
3969
- | --- | --- |
3970
- | `mute` | **Record it, and keep the history.** The microphone is the host's, so Omni has already stopped the audio through `VoiceMediaSession.setMuted()` — no adapter can do that on the host's behalf. The command still arrives because the provider owns the task's record: it holds the current state for supervision, and each change as a `muted` handling step, exactly as it does for `held`. A platform that never hears about it shows a supervisor an agent who sounds absent for no reason, and reports a call with a silence it cannot explain. |
3971
- | Every other command | **Perform it.** `hold`, `transfer`, `conference`, `end-call`, `recording` and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them. |
3985
+ Every command reaches the provider through `execute`, with no branch at the call site, and every
3986
+ command asks the provider to **perform** something: `hold`, `transfer`, `conference`, `end-call`,
3987
+ `recording` and the rest act on the platform's own call leg, its bridge, or its record of the
3988
+ task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
3972
3989
 
3973
3990
  **The provider performs every action; the host only offers it.** A control drawn on the host is
3974
3991
  an affordance, never the enforcement: the host asks, and the provider does or declines. A provider
@@ -3977,16 +3994,75 @@ honouring its own declaration, not the host deciding policy -- a host that refus
3977
3994
  its own reading of a queue's flag has decided something that was never its to decide. What a
3978
3995
  command means on the platform is the provider's to work out: `end-call` ends the conversation, and
3979
3996
  which legs on which bridge that touches is a fact about the switch, never a choice the host makes.
3980
- The one leg the host performs physically is the microphone, and even there the provider owns the
3981
- record.
3982
-
3983
- **A failed `mute` does not unmute the agent.** The agent asked, Omni holds the microphone, and it
3984
- is already done; a failure means only that the provider did not record it, leaving its view stale
3985
- until the next snapshot. That is the safe direction to fail in, and it is the one place where
3986
- `failed` does not mean *nothing happened* — everywhere else it does.
3987
-
3988
- `mute` carries `muted` rather than flipping, so a stale view converges on the stated state
3989
- instead of flipping it back — see **Task commands**.
3997
+ The one thing the host performs physically is the microphone, and that is not a command at all.
3998
+
3999
+ ### The station is the host's
4000
+
4001
+ The microphone, the speaker, the headset and its buttons are the station's, and the station is the
4002
+ host's. So mute is not a capability a provider declares, not a control a queue allows or a team
4003
+ locks, not a preference the person keeps with the provider, and not a command: nothing about the
4004
+ station crosses to the provider except two things -- the station's condition, in the host report,
4005
+ and the record of when the agent could not be heard, through `recordStep`. A host offers Mute on
4006
+ every voice task with media open, under its own provisioning, in `in-progress` and `paused`
4007
+ alone, and performs it through `VoiceMediaSession.setMuted()`. A task carrying
4008
+ `capabilities.mute` is refused (`task.capability.unknown`), and so is a `mute` command
4009
+ (`command.type`), a `mute` preference (`preference.id`) and a `mute` policy (`team.policy.key`).
4010
+
4011
+ **Two mutes, and the host states which its Mute performs.** A *stream* mute stops the audio the
4012
+ host sends: the microphone keeps capturing, the party hears silence, nothing else on the machine
4013
+ changes, and every softphone host can do it. A *station* mute silences the microphone itself at
4014
+ the operating system: every application on the machine goes quiet, the system shows it, and a
4015
+ headset that follows the system follows it. Only a native host can do it. `Host.mute` says which
4016
+ this host does -- `stream` or `station` -- stated on a softphone login, where the host holds the
4017
+ microphone, and absent on a desk phone and off voice (`host.mute.required` / `.unexpected`). A
4018
+ browser says `stream`. A native host says whichever it does. Nothing on the wire says what kind of
4019
+ application the host is; a provider reads only what the host declared.
4020
+
4021
+ **A silenced device says who silenced it.** `audio.input.flowing: false` and
4022
+ `audio.output.flowing: false` carry `mutedBy`: `host` when the host's own Mute did it -- which is
4023
+ only ever true of a host whose mute is `station`, since a stream mute leaves the microphone
4024
+ flowing -- and `station` when a slider on the headset or the operating system did, which the host
4025
+ observed and did not do. Without the word, a station-muting host's own Mute would read as a fault
4026
+ and an adapter would take the agent out of ready in the middle of a call. A browser reads a station
4027
+ mute through the capture track's muted state, read-only, and cannot lift it; a native host reads
4028
+ the endpoint and can clear an operating-system mute. A hardware slider is nobody's to clear.
4029
+
4030
+ **The record carries the same word.** A `muted` handling leg is a period the agent could not be
4031
+ heard, and the record exists so that period is not a hole. The host reports every such period
4032
+ through `recordStep` -- its own Mute, and a station mute it observed during a call -- with
4033
+ `mutedBy` saying whose the silence was, and the provider writes the word into the entry
4034
+ (`task.handlingHistory.mutedBy`). A supervisor reading the record then sees "the agent muted for
4035
+ forty seconds" and "the agent's headset was muted for forty seconds" as the different things they
4036
+ are. See **The host records what it performs**.
4037
+
4038
+ **Mute has a lifecycle, and none of it is inferred.** A call that starts while the station is
4039
+ already muted begins a `muted` leg the moment its media starts, `mutedBy: "station"`. Media that
4040
+ ends while any leg is open ends the leg at that instant, as every host-performed leg ends. And the
4041
+ host's own Mute starts off on every call: an agent is never muted by the call before.
4042
+
4043
+ **Every press on a headset is the agent's own press**, performed the host's way, and the lights
4044
+ follow the host's state. The `personConsent` guarantee is honoured by a hook-switch press exactly
4045
+ as by a click. What a call-control headset sends, and what becomes of it:
4046
+
4047
+ | Input | Where it comes from | What the host does | What crosses to the provider |
4048
+ | --- | --- | --- | --- |
4049
+ | Mute button | The headset, on the HID Telephony usage page: a Phone Mute report on the press, a Mute LED the host writes back | A press of the host's Mute, performed the host's way; the LED follows the host's state, so button, light and control are one state with one owner | The `muted` leg, `mutedBy: "host"` |
4050
+ | Hook switch | The headset, HID Telephony | A press of the host's Answer or End call, only where the task is in a phase that has one; the off-hook and ring lights follow the task | The existing `answer` and `end-call` commands |
4051
+ | Flash, redial, speed dial | Older headsets and desk-phone style devices | Flash is Hold and Resume where the task offers `hold`; redial and speed dial map to nothing on a desk with no dial pad on a transfer, and are ignored | Nothing new |
4052
+ | Volume up and down | The headset's own amplifier, or the operating system through the consumer-control keys | Nothing: the device and the system handle it | Nothing, except that a speaker at nought is `audio.output.flowing: false` where the host can know it |
4053
+ | Microphone gain and level | The operating system, the headset's own boost | A level meter, so the agent can see they are heard | Nothing: a level is a flicker, not a state |
4054
+ | Hardware or operating-system mute | A slider on the headset, the system's input mute | Observes it, publishes `flowing: false, mutedBy: "station"`, records the leg during a call, and tells the agent which it is where it knows -- headset or system -- and what to do; a host whose mute is `station` clears a system mute itself, a browser can only say so | The report and the leg |
4055
+ | Device changed, unplugged, permission revoked | The operating system | Re-capture, prompt, report | Already `audio.input` and `audio.output` with `lost`, `denied`, `no-device` |
4056
+
4057
+ **What the host shows.** Beside Mute, a standing line while the station is muted, saying which and
4058
+ what to do, with a button that clears a system mute only on a host whose mute is `station`. The
4059
+ Mute control does not pretend: while the station is muted it shows the agent cannot be heard and
4060
+ that pressing it will not change that, and a press still toggles the host's own mute, so the two
4061
+ states stay separate and the agent is not unmuted by surprise when the slider moves back. Before a
4062
+ call, the same line in the ready state, so an agent does not learn of it from a silent customer;
4063
+ whether work is held back is the adapter's decision from the report, as it is for every station
4064
+ fact. Where a provider's own platform holds a mute of its own -- a bridge that silences a leg --
4065
+ that is a fact about the switch, reported as the provider sees fit, and never the host's Mute.
3990
4066
 
3991
4067
  ### Which commands need a capability
3992
4068
 
@@ -4009,6 +4085,17 @@ declared:
4009
4085
  | `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
4010
4086
  | Everything else | Its own named capability. |
4011
4087
 
4088
+ **A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
4089
+ `hold`, `resume` and `pause`, `end-call`, `recording`, every `transfer` and `conference` action, and
4090
+ every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
4091
+ Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
4092
+ call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
4093
+ for nothing. The capability stays declared, because it is a property of the task and the task is
4094
+ still open; the phase says there is nothing to use it on. Omni shows none of these controls outside
4095
+ the two phases, and `validateTaskCommand` refuses each of them there (`command.phase.handling`).
4096
+ The commands with a phase of their own -- `answer`, `accept`, `decline` and `reject` in `pending`,
4097
+ `call` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
4098
+
4012
4099
  `validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
4013
4100
  capability it needs, the phase it belongs to, and the state that has to stand. The task it wants
4014
4101
  is the one the provider published, not a host's own mapping of it: a host that keeps only its
@@ -4376,8 +4463,9 @@ same exported checks are used by Omni and adapter tests so their interpretations
4376
4463
  | `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
4377
4464
  | `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
4378
4465
  | `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
4466
+ | `validateHostMute(mute, softphone)` | What the host's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the host holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
4379
4467
  | `validateAuthenticationResult(result, method)` | What `start()` or `complete()` answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an `omni.` code the contract lists, and `omni.phone-not-permitted` never retryable, since the agent's station is configuration. `validateAuthenticationFailure(failure)` is the same check on a failure alone. |
4380
- | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
4468
+ | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`, `command.phase.handling` for every control on the call or the conversation), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
4381
4469
  | `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
4382
4470
  | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
4383
4471
 
@@ -4448,7 +4536,14 @@ complete it with a disposition where the agent completes -- and holds every step
4448
4536
  host holds a provider to. Each command is validated against the task as published
4449
4537
  (`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
4450
4538
  violation (`drive.command.failed`), and an event the provider owes and never sends is one too
4451
- (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). A task the agent completes is
4539
+ (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). With the audio open on a softphone,
4540
+ the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
4541
+ expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
4542
+ restates the task's record afterwards, the leg is in it or the hole is named
4543
+ (`drive.recordStep.history`). Once the call has ended and the task is
4544
+ `completing`, the drive sends `hold` once more, past the validator that would hold it back, and
4545
+ expects `failed`: the adapter is the second gate on a control on the contact, and one that applies
4546
+ it on a call that is over is named (`drive.command.handling`). A task the agent completes is
4452
4547
  completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
4453
4548
  after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
4454
4549
  email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",