@xema/omni-protocol 0.1.34 → 0.1.36

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
@@ -77,11 +77,13 @@ export interface DialCapability {
77
77
  /**
78
78
  * How a dial ended, as the switch distinguishes it. `answered` is the one success; the rest are
79
79
  * the ways a destination was not reached -- `cancelled` because the dialler called it off before
80
- * anyone answered, not because the destination failed to. A manifest declares which of these its
81
- * platform can tell apart (`dialOutcomes`), and a `dial-outcome` carries only a declared one.
80
+ * anyone answered, not because the destination failed to, and `unexplained` because the switch
81
+ * dropped the call and gave no cause, a statement about the switch rather than a shrug from the
82
+ * adapter. A manifest declares which of these its platform can tell apart (`dialOutcomes`), and a
83
+ * `dial-outcome` carries only a declared one.
82
84
  */
83
- export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled";
84
- export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled"];
85
+ export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";
86
+ export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
85
87
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
86
88
  export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
87
89
  export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
@@ -574,13 +576,18 @@ export type TaskCompletion = {
574
576
  wrapAllowance: DurationSeconds;
575
577
  };
576
578
  export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
579
+ /** Where a dialled entry stands: `ringing` until its dial is answered, `joined` after. */
580
+ export type OnCallStage = "ringing" | "joined";
577
581
  export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];
578
582
  /**
579
- * Somebody on the call right now, as the provider states it: who, since when, and whether they
580
- * are held aside. `party` is the customer; `agent` a person, by user id; `consulted` and
581
- * `conferenced` someone a dial brought in, carrying the dial when a host placed it and the address
582
- * either way. `label` names a destination -- a person, a queue -- not a phrase. At most one
583
- * `party` and one `consulted`: the consult commands name neither because there is exactly one.
583
+ * Somebody on the call, or being brought onto it, as the provider states it: who, since when, and
584
+ * whether they are held aside. `party` is the customer; `agent` a person, by user id; `consulted`
585
+ * and `conferenced` someone a dial is bringing in -- listed from the moment the dial is placed, so
586
+ * the agent can call it off while it rings -- carrying the dial when a host placed it, the address
587
+ * either way, and the `stage` it has reached: `ringing` until the dial is answered, `joined` after,
588
+ * stated so a snapshot says who is present without anyone having seen the outcome. Nobody ringing is
589
+ * held. `label` names a destination -- a person, a queue -- not a phrase. At most one `party` and
590
+ * one `consulted`: the consult commands name neither because there is exactly one.
584
591
  */
585
592
  export type OnCall = {
586
593
  since: IsoTimestamp;
@@ -593,6 +600,7 @@ export type OnCall = {
593
600
  } | {
594
601
  role: "consulted" | "conferenced";
595
602
  destination: string;
603
+ stage: OnCallStage;
596
604
  dialId?: DialId;
597
605
  label?: string;
598
606
  });
@@ -804,7 +812,7 @@ export type VoiceTaskCommand = {
804
812
  dialId: DialId;
805
813
  destination: string;
806
814
  }
807
- /** Drop the `conferenced` entry on `Task.onCall` with this destination. */
815
+ /** Drop the `conferenced` entry on `Task.onCall` with this destination; while it still rings, this calls the dial off. */
808
816
  | {
809
817
  type: "conference";
810
818
  action: "remove";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
15
15
  const shared = adapterVersions.filter(version => host.has(version));
16
16
  return shared.length === 0 ? undefined : Math.max(...shared);
17
17
  }
18
- export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled"];
18
+ export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
19
19
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
20
20
  export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
21
21
  /** What Omni calls each idle capability. */
@@ -67,8 +67,11 @@ const ACCESS_APPLIES_TO = membersOf({
67
67
  "initial-url": true, "all-navigation": true,
68
68
  });
69
69
  const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number": true });
70
- const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true });
70
+ const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true, unexplained: true });
71
+ /** The outcomes that name a cause. `unexplained` is declarable only beside one of these. */
72
+ const EXPLAINED_FAILURES = ["busy", "no-answer", "unreachable", "rejected", "cancelled"];
71
73
  const ON_CALL_ROLES = membersOf({ party: true, agent: true, consulted: true, conferenced: true });
74
+ const ON_CALL_STAGES = membersOf({ ringing: true, joined: true });
72
75
  /** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
73
76
  const DIALLING_CAPABILITIES = ["callback", "blindTransfer", "consultTransfer", "conference"];
74
77
  const SNAPSHOT_REASONS = membersOf({
@@ -297,6 +300,11 @@ function validateDialOutcomes(manifest, path, into) {
297
300
  });
298
301
  into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
299
302
  into.require(declared.some((outcome) => outcome !== "answered"), "manifest.dialOutcomes.failure", path, "a provider that can only say answered can never say a destination was not reached: declare at least one other outcome");
303
+ // "The switch gave no cause" is honest only from a provider that reports the cause when there is
304
+ // one; declared alone it is the generic failure every unsure provider would send.
305
+ if (declared.includes("unexplained")) {
306
+ into.require(declared.some((outcome) => EXPLAINED_FAILURES.includes(outcome)), "manifest.dialOutcomes.unexplained.alone", path, "unexplained says the switch gave no cause, which is a claim only a provider that reports causes can make: declare at least one of busy, no-answer, unreachable, rejected, cancelled beside it");
307
+ }
300
308
  }
301
309
  /** Whether a manifest says how a dial ends, and so may publish tasks that dial. `undefined` where there is no manifest to ask. */
302
310
  function manifestDials(manifest) {
@@ -771,13 +779,17 @@ function validateOnCall(value, channel, path, into) {
771
779
  }
772
780
  if (role === "consulted" || role === "conferenced") {
773
781
  into.filled(entry.destination, "task.onCall.destination", `${at}.destination`, `a ${role} entry names the destination that was dialled`);
782
+ // Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
783
+ if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
784
+ into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
785
+ }
774
786
  if (entry.dialId !== undefined)
775
787
  into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
776
788
  if (entry.label !== undefined)
777
789
  into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
778
790
  }
779
791
  else {
780
- for (const field of ["destination", "dialId", "label"]) {
792
+ for (const field of ["destination", "dialId", "label", "stage"]) {
781
793
  into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
782
794
  }
783
795
  }
package/guide.md CHANGED
@@ -36,7 +36,7 @@ are used precisely throughout and mean nothing looser here.
36
36
  | **Break** | A reported, supervised state in which the agent is not working — one with a reason, a decision behind it and a return. It covers what a platform may call *not-ready*, including equipment trouble. An agent who is merely at capacity is not on a break. |
37
37
  | **Workspace** | What Omni shows the agent. The **task workspace** holds the selected task, its controls and its browsers; the **idle workspace** holds what a provider contributes when no task is selected — dialpad, contacts, calendar, roster. |
38
38
  | **Dial** | One outbound call the host asks a provider to place — from the idle dialpad, a transfer, a conference add, or a callback. Identified by the host's `dialId`, accepted as `dialling`, and ended by exactly one `dial-outcome`. See **Every dial has an outcome**. |
39
- | **On the call** | Who a voice task's audio joins right now, as the provider states it on `Task.onCall`: the party, the agents, and anyone consulted or conferenced in. |
39
+ | **On the call** | Who a voice task's audio joins, or is bringing in, as the provider states it on `Task.onCall`: the party, the agents, and anyone consulted or conferenced in from the moment their dial is placed. |
40
40
 
41
41
  Six words describe *what state a thing is in*, and they are not interchangeable: each belongs to
42
42
  one thing, so a bare "status" in conversation is always the authentication session's, and a
@@ -165,7 +165,7 @@ type DialDestinations = "contacts-only" | "any-number";
165
165
 
166
166
  type DialCapability = { destinations: DialDestinations };
167
167
 
168
- type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled";
168
+ type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";
169
169
 
170
170
  type IdleCapabilities<C extends Channel = Channel> = {
171
171
  personalBrowser?: PersonalBrowserCapability;
@@ -589,10 +589,12 @@ type TaskCompletion =
589
589
 
590
590
  type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
591
591
 
592
+ type OnCallStage = "ringing" | "joined";
593
+
592
594
  type OnCall = { since: IsoTimestamp; held?: true } & (
593
595
  | { role: "party" }
594
596
  | { role: "agent"; userId: UserId }
595
- | { role: "consulted" | "conferenced"; destination: string; dialId?: DialId; label?: string }
597
+ | { role: "consulted" | "conferenced"; destination: string; stage: OnCallStage; dialId?: DialId; label?: string }
596
598
  );
597
599
 
598
600
  type TaskLead = {
@@ -1399,6 +1401,10 @@ export default defineAdapter({
1399
1401
  });
1400
1402
  ```
1401
1403
 
1404
+ `dialOutcomes` is a compile error off voice, as `dial` is. An adapter written once over
1405
+ `C extends Channel` therefore sets it the way it already sets `idleCapabilities`, with
1406
+ `as Pick<Manifest<C>, "dialOutcomes">`; an adapter written for one channel needs no cast.
1407
+
1402
1408
  ### `Adapter.manifest`
1403
1409
 
1404
1410
  Static metadata that Omni can inspect before connecting.
@@ -2129,7 +2135,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2129
2135
  | `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
2130
2136
  | `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
2131
2137
  | `handlingHistory` | The call record: `steps` — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `handleSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **How a task has been handled**. |
2132
- | `onCall` | Voice only. Who is on the call right now, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial brought in -- with the `destination` dialled, the `dialId` where a host placed it, and `held: true` on anyone parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
2138
+ | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destination` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
2133
2139
  | `lead` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Consulting a lead**. |
2134
2140
  | `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead` `take-over` and `leave` issuable. See **Consulting a lead**. |
2135
2141
 
@@ -2558,7 +2564,7 @@ const taskCapabilities = {
2558
2564
  | `blindTransfer` | Secondary menu item: Blind transfer | Omni may transfer the caller directly to a destination. |
2559
2565
  | `consultTransfer` | Secondary menu item: Consult transfer | Omni may park the customer and call a destination first, then hand the customer over or cancel back. See **Consult transfer**. |
2560
2566
  | `consultLead` | Secondary menu item: Consult lead | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.lead`. See **Consulting a lead**. |
2561
- | `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and drop a `conferenced` entry from it. |
2567
+ | `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and drop a `conferenced` entry from it -- one still ringing included, which calls the dial off. |
2562
2568
  | `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
2563
2569
  | `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
2564
2570
 
@@ -2779,12 +2785,17 @@ nothing, is refused (`result.status`). `validateResult(result, method, path, dia
2779
2785
 
2780
2786
  **The outcome is stated, once, either way.** A `dial-outcome` names the `dialId`, the task where
2781
2787
  there was one, the destination where the provider knows it, and how the dial ended -- from a closed
2782
- set: `answered`, `busy`, `no-answer`, `unreachable`, `rejected`, `cancelled`. `answered` is stated
2783
- rather than read off somebody appearing on the call, because a host that infers success cannot tell
2784
- "reached" from "still ringing". `cancelled` is the dialler calling the dial off before anyone
2785
- answered -- the agent hanging up while a consult still rings -- which is not a `no-answer` the
2786
- destination never gave. `reason` is the switch's own words, optional, and shown to the agent
2787
- attributed to the switch rather than to Omni.
2788
+ set: `answered`, `busy`, `no-answer`, `unreachable`, `rejected`, `cancelled`, `unexplained`.
2789
+ `answered` is stated rather than read off somebody appearing on the call, because a host that infers
2790
+ success cannot tell "reached" from "still ringing". `cancelled` is the dialler calling the dial off
2791
+ before anyone answered -- the agent hanging up while a consult still rings -- which is not a
2792
+ `no-answer` the destination never gave. `unexplained` is the switch dropping the call with no cause among
2793
+ those named here: a statement about the switch, sent instead of the nearest cause it did not give.
2794
+ `reason` is the switch's own words, optional, and shown to the agent attributed to the switch rather
2795
+ than to Omni. The two are different things and may travel together: a switch can say "could not
2796
+ create dialog" in words and still name no cause a code would carry, so `unexplained` with a
2797
+ `reason` is a dial the switch described but did not classify, and a desk renders the words without
2798
+ inventing the class.
2788
2799
 
2789
2800
  **`taskId` is the task the dial was placed on**, resolved from the dial's own identity and never from
2790
2801
  whatever task the agent is looking at when the outcome arrives. That task may already have ended,
@@ -2792,6 +2803,12 @@ and the harness places the outcome all the same (`TaskStream`, `stream.dialOutco
2792
2803
  `stream.dialOutcome.duplicate`); an agent who moved on still learns nobody was reached, against the
2793
2804
  call it belonged to and never against the next one.
2794
2805
 
2806
+ The stream knows a dial from three places: `TaskStream.dialled(dialId)`, which a host calls when it
2807
+ places one; an entry on a task's `onCall`; and a step in its `handlingHistory`. Since a dialled entry
2808
+ is listed from placement, `dialled()` is load-bearing only for a dial that never has an entry -- a
2809
+ blind transfer, a callback, an idle dialpad call -- or whose entry has already left the room; an
2810
+ outcome for a dial nobody placed and no task mentions is what `stream.dialOutcome.unknown` refuses.
2811
+
2795
2812
  **`answered` says what happened to the dial; `onCall` says who is in the room.** They are two
2796
2813
  claims, and both are true at once when a leg answers and never joins. A desk shows `answered` as the
2797
2814
  dial's conclusion and says "on the call" only when the person appears on `Task.onCall`; the obvious
@@ -2802,7 +2819,11 @@ provider that cannot tell busy from unreachable must not pick one. The manifest
2802
2819
  `dialOutcomes`, and a `dial-outcome` carries only a declared member
2803
2820
  (`event.dialOutcome.undeclared`). The list includes `answered`, or the provider could never state a
2804
2821
  success (`manifest.dialOutcomes.answered`), and at least one other member, or it could never state
2805
- a failure (`manifest.dialOutcomes.failure`). A provider that dials at all declares it: an idle
2822
+ a failure (`manifest.dialOutcomes.failure`). `unexplained` is declarable only beside a cause the
2823
+ provider does report -- `busy`, `no-answer`, `unreachable`, `rejected` or `cancelled`
2824
+ (`manifest.dialOutcomes.unexplained.alone`): a provider that reports causes may honestly say the
2825
+ switch gave none, while one that reports none would be sending the generic failure this set exists
2826
+ to refuse. A provider that dials at all declares it: an idle
2806
2827
  dialpad requires it (`manifest.dialOutcomes.required`), and a task that declares `callback`,
2807
2828
  `blindTransfer`, `consultTransfer` or `conference` under a manifest without it is refused
2808
2829
  (`task.capability.dialOutcomes.required`). Off voice nothing dials, and the field is a compile
@@ -2816,7 +2837,8 @@ knows the room:
2816
2837
  const onCall: OnCall[] = [
2817
2838
  { role: "party", since: "2026-08-21T09:12:00Z" },
2818
2839
  { role: "agent", userId: "A-12", since: "2026-08-21T09:12:04Z" },
2819
- { role: "conferenced", destination: "+14155550111", dialId: "dial-7f2", held: true, since: "2026-08-21T09:15:30Z" },
2840
+ { role: "conferenced", destination: "+14155550111", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
2841
+ { role: "conferenced", destination: "+14155550199", dialId: "dial-3c9", stage: "ringing", since: "2026-08-21T09:16:02Z" },
2820
2842
  ];
2821
2843
  ```
2822
2844
 
@@ -2825,7 +2847,25 @@ neither carries a destination. `consulted` and `conferenced` were, so both carry
2825
2847
  and, where a host's dial brought them, its `dialId`; a person the platform added itself carries
2826
2848
  none. `held: true` is presence as claim. A snapshot establishes state, so a host that attaches
2827
2849
  after a transfer reads the room from here rather than inferring it from a sequence of outcomes it
2828
- never saw. A `conference` `remove` names the `destination` of the `conferenced` entry to drop.
2850
+ never saw.
2851
+
2852
+ **A dialled entry is listed from the moment the dial is placed, not from the answer.** The entry is
2853
+ what makes `transfer` `cancel` issuable and what `conference` `remove` names, and a destination the
2854
+ agent cannot call off while it rings is a parked customer with no way back -- which is the very case
2855
+ `cancelled` exists for. Consult and conference behave alike here: the `consulted` or `conferenced`
2856
+ entry appears with its `dialId` when the dial is placed, and a `conference` `remove` naming a
2857
+ still-ringing `destination` calls that dial off, with `cancelled` as its outcome.
2858
+
2859
+ **The entry says where it stands.** A dialled entry carries `stage`: `ringing` until its dial is
2860
+ answered, `joined` after. The `dial-outcome` is the transition and the stage is the state, the same
2861
+ pairing as `task-media-started` and `media`: an `answered` outcome and the task restated with the
2862
+ entry `joined` say one thing twice, and any other outcome removes the entry. It is stated rather
2863
+ than left to whoever placed the dial because that knowledge lives in one process: after a reload,
2864
+ on a second session, or on a supervisor's screen the snapshot is the only source, and a room that
2865
+ cannot tell ringing from joined says it contains someone who may never arrive. Nobody ringing is
2866
+ held (`task.onCall.held.ringing`). A consult destination that never answers is a dial that
2867
+ ended: the provider returns the task to `in-progress` with no `consulted` entry, as **Consult
2868
+ transfer** states, so the customer is never left parked by a conforming provider.
2829
2869
 
2830
2870
  The desk shows a dial as placed on `dialling`, never as reached; shows the person in the room when
2831
2871
  they appear on `onCall`; and shows the outcome, with the switch's reason, against the call it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",