@dg-kit/safety 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +16 -0
  2. package/dist/contracts.d.ts +44 -0
  3. package/dist/contracts.d.ts.map +1 -0
  4. package/dist/contracts.js +9 -0
  5. package/dist/contracts.js.map +1 -0
  6. package/dist/default-policies.d.ts +36 -0
  7. package/dist/default-policies.d.ts.map +1 -0
  8. package/dist/default-policies.js +333 -0
  9. package/dist/default-policies.js.map +1 -0
  10. package/dist/device-command-queue.d.ts +60 -0
  11. package/dist/device-command-queue.d.ts.map +1 -0
  12. package/dist/device-command-queue.js +105 -0
  13. package/dist/device-command-queue.js.map +1 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +17 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lifecycle-guard.d.ts +30 -0
  19. package/dist/lifecycle-guard.d.ts.map +1 -0
  20. package/dist/lifecycle-guard.js +59 -0
  21. package/dist/lifecycle-guard.js.map +1 -0
  22. package/dist/policy-engine.d.ts +45 -0
  23. package/dist/policy-engine.d.ts.map +1 -0
  24. package/dist/policy-engine.js +31 -0
  25. package/dist/policy-engine.js.map +1 -0
  26. package/dist/safety-acceptance.d.ts +27 -0
  27. package/dist/safety-acceptance.d.ts.map +1 -0
  28. package/dist/safety-acceptance.js +64 -0
  29. package/dist/safety-acceptance.js.map +1 -0
  30. package/dist/safety-bus.d.ts +156 -0
  31. package/dist/safety-bus.d.ts.map +1 -0
  32. package/dist/safety-bus.js +169 -0
  33. package/dist/safety-bus.js.map +1 -0
  34. package/dist/safety-notice-content.d.ts +37 -0
  35. package/dist/safety-notice-content.d.ts.map +1 -0
  36. package/dist/safety-notice-content.js +72 -0
  37. package/dist/safety-notice-content.js.map +1 -0
  38. package/package.json +38 -0
@@ -0,0 +1,9 @@
1
+ export * from './contracts.js';
2
+ export * from './policy-engine.js';
3
+ export * from './default-policies.js';
4
+ export * from './device-command-queue.js';
5
+ export * from './safety-bus.js';
6
+ export * from './lifecycle-guard.js';
7
+ export * from './safety-notice-content.js';
8
+ export * from './safety-acceptance.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // @dg-kit/safety — the single source of truth for the device safety chain.
2
+ //
3
+ // Policy engine (strength caps / cold-start clamp / burst-specific caps /
4
+ // cumulative ceiling), default policies, and the serial command queue (with
5
+ // emergency-stop preemption and generation invalidation). Before the merge
6
+ // DG-Agent and DG-Voice each held a copy; stripped of imports and comments
7
+ // the two were byte-identical, so merging involved no behavioral judgment
8
+ // calls.
9
+ export * from './contracts.js';
10
+ export * from './policy-engine.js';
11
+ export * from './default-policies.js';
12
+ export * from './device-command-queue.js';
13
+ export * from './safety-bus.js';
14
+ export * from './lifecycle-guard.js';
15
+ export * from './safety-notice-content.js';
16
+ export * from './safety-acceptance.js';
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,EAAE;AACF,0EAA0E;AAC1E,4EAA4E;AAC5E,2EAA2E;AAC3E,2EAA2E;AAC3E,0EAA0E;AAC1E,SAAS;AACT,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Stops device output when the page goes away or into the background.
3
+ *
4
+ * There used to be two of these — DG-Agent's `BrowserSafetyGuard` and
5
+ * DG-Voice's `CallSafetyGuard`, near-identical down to the in-flight guard
6
+ * and the listener set. They differed in exactly one way: Agent made
7
+ * "keep running while backgrounded" a user setting, Voice always stopped.
8
+ *
9
+ * This keeps Voice's behavior, for everyone. Screen off or tab away means
10
+ * stop, unconditionally, and there is no option to turn that off — which is
11
+ * also why the two can now be one class without the earlier worry that a
12
+ * wrong default would quietly ship "keeps firing while backgrounded" to a
13
+ * module whose author never chose it.
14
+ *
15
+ * Android already worked this way regardless of the setting (see the shell's
16
+ * lifecycle safety), so the setting was already a lie on the platform where
17
+ * it mattered most.
18
+ */
19
+ export type LifecycleStopReason = 'leave-page' | 'background-hidden';
20
+ export interface DeviceLifecycleGuardOptions {
21
+ /** Called to stop output. Must be idempotent — the guard may fire more than once. */
22
+ onStop: (reason: LifecycleStopReason) => void | Promise<void>;
23
+ }
24
+ export declare class DeviceLifecycleGuard {
25
+ private readonly onStop;
26
+ constructor(options: DeviceLifecycleGuardOptions);
27
+ /** Begin watching. Returns the unsubscribe. */
28
+ start(): () => void;
29
+ }
30
+ //# sourceMappingURL=lifecycle-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle-guard.d.ts","sourceRoot":"","sources":["../src/lifecycle-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,mBAAmB,GAAG,YAAY,GAAG,mBAAmB,CAAC;AAErE,MAAM,WAAW,2BAA2B;IAC1C,qFAAqF;IACrF,MAAM,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED,qBAAa,oBAAoB;IAI/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAwC;gBAEnD,OAAO,EAAE,2BAA2B;IAIhD,+CAA+C;IAC/C,KAAK,IAAI,MAAM,IAAI;CAgCpB"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Stops device output when the page goes away or into the background.
3
+ *
4
+ * There used to be two of these — DG-Agent's `BrowserSafetyGuard` and
5
+ * DG-Voice's `CallSafetyGuard`, near-identical down to the in-flight guard
6
+ * and the listener set. They differed in exactly one way: Agent made
7
+ * "keep running while backgrounded" a user setting, Voice always stopped.
8
+ *
9
+ * This keeps Voice's behavior, for everyone. Screen off or tab away means
10
+ * stop, unconditionally, and there is no option to turn that off — which is
11
+ * also why the two can now be one class without the earlier worry that a
12
+ * wrong default would quietly ship "keeps firing while backgrounded" to a
13
+ * module whose author never chose it.
14
+ *
15
+ * Android already worked this way regardless of the setting (see the shell's
16
+ * lifecycle safety), so the setting was already a lie on the platform where
17
+ * it mattered most.
18
+ */
19
+ export class DeviceLifecycleGuard {
20
+ // Not a constructor parameter property: `erasableSyntaxOnly` is on in Chat
21
+ // and the Android shell, and that syntax fails their build with TS1294
22
+ // while typecheck stays green.
23
+ onStop;
24
+ constructor(options) {
25
+ this.onStop = options.onStop;
26
+ }
27
+ /** Begin watching. Returns the unsubscribe. */
28
+ start() {
29
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
30
+ return () => undefined;
31
+ }
32
+ // One stop at a time: pagehide and visibilitychange both fire when a tab
33
+ // is closed, and a second stop landing mid-flight would race the first.
34
+ let inFlight = null;
35
+ const stop = (reason) => {
36
+ if (inFlight)
37
+ return;
38
+ inFlight = Promise.resolve(this.onStop(reason)).finally(() => {
39
+ inFlight = null;
40
+ });
41
+ };
42
+ const onLeave = () => stop('leave-page');
43
+ const onVisibility = () => {
44
+ if (document.visibilityState === 'hidden')
45
+ stop('background-hidden');
46
+ };
47
+ // Both leave events: pagehide is the reliable one on mobile Safari, and
48
+ // beforeunload is what fires on desktop closes.
49
+ window.addEventListener('pagehide', onLeave);
50
+ window.addEventListener('beforeunload', onLeave);
51
+ document.addEventListener('visibilitychange', onVisibility);
52
+ return () => {
53
+ window.removeEventListener('pagehide', onLeave);
54
+ window.removeEventListener('beforeunload', onLeave);
55
+ document.removeEventListener('visibilitychange', onVisibility);
56
+ };
57
+ }
58
+ }
59
+ //# sourceMappingURL=lifecycle-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle-guard.js","sourceRoot":"","sources":["../src/lifecycle-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AASH,MAAM,OAAO,oBAAoB;IAC/B,2EAA2E;IAC3E,uEAAuE;IACvE,+BAA+B;IACd,MAAM,CAAwC;IAE/D,YAAY,OAAoC;QAC9C,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAC/B,CAAC;IAED,+CAA+C;IAC/C,KAAK;QACH,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;YACrE,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC;QACzB,CAAC;QAED,yEAAyE;QACzE,wEAAwE;QACxE,IAAI,QAAQ,GAAyB,IAAI,CAAC;QAC1C,MAAM,IAAI,GAAG,CAAC,MAA2B,EAAE,EAAE;YAC3C,IAAI,QAAQ;gBAAE,OAAO;YACrB,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE;gBAC3D,QAAQ,GAAG,IAAI,CAAC;YAClB,CAAC,CAAC,CAAC;QACL,CAAC,CAAC;QAEF,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QACzC,MAAM,YAAY,GAAG,GAAG,EAAE;YACxB,IAAI,QAAQ,CAAC,eAAe,KAAK,QAAQ;gBAAE,IAAI,CAAC,mBAAmB,CAAC,CAAC;QACvE,CAAC,CAAC;QAEF,wEAAwE;QACxE,gDAAgD;QAChD,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;QAC7C,MAAM,CAAC,gBAAgB,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;QACjD,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC;QAE5D,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YAChD,MAAM,CAAC,mBAAmB,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;YACpD,QAAQ,CAAC,mBAAmB,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC;QACjE,CAAC,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1,45 @@
1
+ import type { ActionContext, PolicyDecision } from './contracts.js';
2
+ import type { DeviceCommand, DeviceState, OpossumCommand } from '@dg-kit/core';
3
+ import type { OpossumState } from '@dg-kit/protocol';
4
+ export interface EvaluatePolicyInput {
5
+ context: ActionContext;
6
+ command: DeviceCommand;
7
+ deviceState: DeviceState;
8
+ }
9
+ export interface PolicyRule {
10
+ name: string;
11
+ evaluate(input: EvaluatePolicyInput): PolicyDecision | null;
12
+ }
13
+ export declare class PolicyEngine {
14
+ private readonly rules;
15
+ constructor(rules: PolicyRule[]);
16
+ evaluate(input: EvaluatePolicyInput): PolicyDecision;
17
+ }
18
+ export type OpossumPolicyDecision = {
19
+ type: 'allow';
20
+ } | {
21
+ type: 'deny';
22
+ reason: string;
23
+ } | {
24
+ type: 'clamp';
25
+ command: OpossumCommand;
26
+ reason: string;
27
+ } | {
28
+ type: 'require-confirm';
29
+ reason: string;
30
+ };
31
+ export interface EvaluateOpossumPolicyInput {
32
+ context: ActionContext;
33
+ command: OpossumCommand;
34
+ deviceState: OpossumState;
35
+ }
36
+ export interface OpossumPolicyRule {
37
+ name: string;
38
+ evaluate(input: EvaluateOpossumPolicyInput): OpossumPolicyDecision | null;
39
+ }
40
+ export declare class OpossumPolicyEngine {
41
+ private readonly rules;
42
+ constructor(rules: OpossumPolicyRule[]);
43
+ evaluate(input: EvaluateOpossumPolicyInput): OpossumPolicyDecision;
44
+ }
45
+ //# sourceMappingURL=policy-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-engine.d.ts","sourceRoot":"","sources":["../src/policy-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC/E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAErD,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,aAAa,CAAC;IACvB,WAAW,EAAE,WAAW,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,KAAK,EAAE,mBAAmB,GAAG,cAAc,GAAG,IAAI,CAAC;CAC7D;AAED,qBAAa,YAAY;IACX,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,UAAU,EAAE;IAEhD,QAAQ,CAAC,KAAK,EAAE,mBAAmB,GAAG,cAAc;CAUrD;AAcD,MAAM,MAAM,qBAAqB,GAC7B;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhD,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,cAAc,CAAC;IACxB,WAAW,EAAE,YAAY,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,KAAK,EAAE,0BAA0B,GAAG,qBAAqB,GAAG,IAAI,CAAC;CAC3E;AAED,qBAAa,mBAAmB;IAClB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,iBAAiB,EAAE;IAEvD,QAAQ,CAAC,KAAK,EAAE,0BAA0B,GAAG,qBAAqB;CAUnE"}
@@ -0,0 +1,31 @@
1
+ export class PolicyEngine {
2
+ rules;
3
+ constructor(rules) {
4
+ this.rules = rules;
5
+ }
6
+ evaluate(input) {
7
+ for (const rule of this.rules) {
8
+ const result = rule.evaluate(input);
9
+ if (result) {
10
+ return result;
11
+ }
12
+ }
13
+ return { type: 'allow' };
14
+ }
15
+ }
16
+ export class OpossumPolicyEngine {
17
+ rules;
18
+ constructor(rules) {
19
+ this.rules = rules;
20
+ }
21
+ evaluate(input) {
22
+ for (const rule of this.rules) {
23
+ const result = rule.evaluate(input);
24
+ if (result) {
25
+ return result;
26
+ }
27
+ }
28
+ return { type: 'allow' };
29
+ }
30
+ }
31
+ //# sourceMappingURL=policy-engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-engine.js","sourceRoot":"","sources":["../src/policy-engine.ts"],"names":[],"mappings":"AAeA,MAAM,OAAO,YAAY;IACM;IAA7B,YAA6B,KAAmB;QAAnB,UAAK,GAAL,KAAK,CAAc;IAAG,CAAC;IAEpD,QAAQ,CAAC,KAA0B;QACjC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,MAAM,EAAE,CAAC;gBACX,OAAO,MAAM,CAAC;YAChB,CAAC;QACH,CAAC;QAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IAC3B,CAAC;CACF;AA+BD,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,KAA0B;QAA1B,UAAK,GAAL,KAAK,CAAqB;IAAG,CAAC;IAE3D,QAAQ,CAAC,KAAiC;QACxC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,MAAM,EAAE,CAAC;gBACX,OAAO,MAAM,CAAC;YAChB,CAAC;QACH,CAAC;QAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;IAC3B,CAAC;CACF"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Acceptance state of the safety notice.
3
+ *
4
+ * One copy for the whole system. Before the merge, Agent and Chat each gated
5
+ * their own entry point and tracked their own state, so inside the unified
6
+ * shell the same notice had to be accepted twice. The notice itself now
7
+ * exists once (see `safety-notice-content.ts`), so its acceptance state must
8
+ * too — confirmed once on entering the app, counted for all modules.
9
+ *
10
+ * The gate itself is not weakened: the default is "show", and only an
11
+ * explicit "do not show again" checkbox is remembered. Without the checkbox
12
+ * the acceptance lasts for the session and the notice shows again next
13
+ * launch.
14
+ */
15
+ /**
16
+ * Whether the user has permanently accepted.
17
+ *
18
+ * Returns false when storage is unreadable — it MUST be false. In private
19
+ * browsing or on a storage error, showing the notice one extra time is fine;
20
+ * silently waving the user through because a read failed is not.
21
+ */
22
+ export declare function isSafetyNoticeAccepted(): boolean;
23
+ /** Persist "do not show again". Call only on an explicit user checkbox. */
24
+ export declare function rememberSafetyNoticeAccepted(): void;
25
+ /** Re-arm the notice (the "show safety notice again" setting). */
26
+ export declare function forgetSafetyNoticeAccepted(): void;
27
+ //# sourceMappingURL=safety-acceptance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safety-acceptance.d.ts","sourceRoot":"","sources":["../src/safety-acceptance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI,OAAO,CAchD;AAED,2EAA2E;AAC3E,wBAAgB,4BAA4B,IAAI,IAAI,CAOnD;AAED,kEAAkE;AAClE,wBAAgB,0BAA0B,IAAI,IAAI,CAOjD"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Acceptance state of the safety notice.
3
+ *
4
+ * One copy for the whole system. Before the merge, Agent and Chat each gated
5
+ * their own entry point and tracked their own state, so inside the unified
6
+ * shell the same notice had to be accepted twice. The notice itself now
7
+ * exists once (see `safety-notice-content.ts`), so its acceptance state must
8
+ * too — confirmed once on entering the app, counted for all modules.
9
+ *
10
+ * The gate itself is not weakened: the default is "show", and only an
11
+ * explicit "do not show again" checkbox is remembered. Without the checkbox
12
+ * the acceptance lasts for the session and the notice shows again next
13
+ * launch.
14
+ */
15
+ const KEY = '0xnullai.safety-accepted';
16
+ /** Per-module keys from before the merge, migrated once on read. */
17
+ const LEGACY_KEYS = ['dg-chat-safety-accepted'];
18
+ /**
19
+ * Whether the user has permanently accepted.
20
+ *
21
+ * Returns false when storage is unreadable — it MUST be false. In private
22
+ * browsing or on a storage error, showing the notice one extra time is fine;
23
+ * silently waving the user through because a read failed is not.
24
+ */
25
+ export function isSafetyNoticeAccepted() {
26
+ if (typeof localStorage === 'undefined')
27
+ return false;
28
+ try {
29
+ if (localStorage.getItem(KEY) === 'true')
30
+ return true;
31
+ for (const legacy of LEGACY_KEYS) {
32
+ if (localStorage.getItem(legacy) === 'true') {
33
+ localStorage.setItem(KEY, 'true');
34
+ return true;
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ // Unreadable storage counts as not accepted.
40
+ }
41
+ return false;
42
+ }
43
+ /** Persist "do not show again". Call only on an explicit user checkbox. */
44
+ export function rememberSafetyNoticeAccepted() {
45
+ try {
46
+ localStorage.setItem(KEY, 'true');
47
+ }
48
+ catch {
49
+ // If the write fails, the notice shows again next time — failing in
50
+ // this direction is safe.
51
+ }
52
+ }
53
+ /** Re-arm the notice (the "show safety notice again" setting). */
54
+ export function forgetSafetyNoticeAccepted() {
55
+ try {
56
+ localStorage.removeItem(KEY);
57
+ for (const legacy of LEGACY_KEYS)
58
+ localStorage.removeItem(legacy);
59
+ }
60
+ catch {
61
+ // Ignore: if this fails the user can still clear site data.
62
+ }
63
+ }
64
+ //# sourceMappingURL=safety-acceptance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safety-acceptance.js","sourceRoot":"","sources":["../src/safety-acceptance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,GAAG,GAAG,0BAA0B,CAAC;AACvC,oEAAoE;AACpE,MAAM,WAAW,GAAG,CAAC,yBAAyB,CAAC,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,UAAU,sBAAsB;IACpC,IAAI,OAAO,YAAY,KAAK,WAAW;QAAE,OAAO,KAAK,CAAC;IACtD,IAAI,CAAC;QACH,IAAI,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QACtD,KAAK,MAAM,MAAM,IAAI,WAAW,EAAE,CAAC;YACjC,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,MAAM,EAAE,CAAC;gBAC5C,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;gBAClC,OAAO,IAAI,CAAC;YACd,CAAC;QACH,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,6CAA6C;IAC/C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,4BAA4B;IAC1C,IAAI,CAAC;QACH,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,oEAAoE;QACpE,0BAA0B;IAC5B,CAAC;AACH,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,0BAA0B;IACxC,IAAI,CAAC;QACH,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QAC7B,KAAK,MAAM,MAAM,IAAI,WAAW;YAAE,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,4DAA4D;IAC9D,CAAC;AACH,CAAC"}
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Safety bus: keeps "stop" reachable regardless of UI state.
3
+ *
4
+ * Each module registers its own emergency-stop function here, so the shell
5
+ * (or any orchestration layer) can stop *every* registered device session in
6
+ * one place, without knowing which modules exist or how each implements its
7
+ * stop.
8
+ *
9
+ * Why it exists: a module that is switched away from gets hidden (it stays
10
+ * mounted so BLE doesn't drop), which means its own stop button is
11
+ * unreachable while its device is *still outputting*. The shell needs a stop
12
+ * anchor that doesn't come and go with module visibility.
13
+ *
14
+ * Three design constraints:
15
+ * 1. `stopAll` must be best-effort — it must NOT abort because one session
16
+ * throws. When one module's BLE is already gone and throws, the other
17
+ * modules' devices are still outputting.
18
+ * 2. The registry is a Map, not an array: on hot reload / remount a module
19
+ * overwrites its entry by id instead of piling up stale closures —
20
+ * otherwise stopAll would invoke functions of unmounted modules.
21
+ * 3. No "remember who was outputting last" optimization. Stop is a
22
+ * low-frequency, high-stakes operation; stopping a few idle devices is
23
+ * fine, missing one because of a state-tracking bug is not.
24
+ */
25
+ /**
26
+ * Summary of one connected device, for the shell's device bar.
27
+ *
28
+ * Lives on the safety bus rather than in a separate registry: the device bar
29
+ * and the stop button are two faces of the same thing — listing devices tells
30
+ * the user what is on their body, and stop must sit right next to that. Two
31
+ * registries could disagree, and the moment they disagree is exactly the
32
+ * dangerous one.
33
+ */
34
+ export interface DeviceSummary {
35
+ /** Stable and unique within one module. */
36
+ id: string;
37
+ /** Device kind: coyote / opossum / paw-prints / civet-edging. */
38
+ kind: string;
39
+ /** User-facing name, usually the BLE advertised name. */
40
+ name: string;
41
+ connected: boolean;
42
+ /** Battery percentage; omitted when unknown. */
43
+ battery?: number;
44
+ /** Whether it is currently outputting. Visual emphasis only — NOT used to decide whether the stop button shows. */
45
+ active?: boolean;
46
+ /** Per-channel strength, for the live readout on the device bar. */
47
+ channels?: {
48
+ label: string;
49
+ value: number;
50
+ max: number;
51
+ }[];
52
+ }
53
+ export interface SafetySession {
54
+ /** Module id, used for overwrite-on-register and diagnostics. */
55
+ id: string;
56
+ /** User-facing name, appears in prompts like "outputting: Agent". */
57
+ label: string;
58
+ /**
59
+ * Whether this module currently *holds a connected device*. Note: not
60
+ * "whether it is outputting".
61
+ *
62
+ * Tying the stop anchor's visibility to "is outputting" makes it depend on
63
+ * a state chain that can break at any time (missed subscription update,
64
+ * lease just revoked while the device is still running). One wrong link and
65
+ * the button disappears at exactly the moment it is needed most. Better to
66
+ * long-show a button that may stop an idle device.
67
+ */
68
+ isActive: () => boolean;
69
+ /** Zero all device output of this module. Must be idempotent — may be called repeatedly. */
70
+ stop: () => void | Promise<void>;
71
+ /** Devices this module currently holds. The shell renders the device bar from it. */
72
+ devices?: () => DeviceSummary[];
73
+ /**
74
+ * Open this module's unified device picker. When present, the shell renders
75
+ * the one connect entry in its top device strip; modules must not duplicate
76
+ * the same action inside their content while mounted in the shell.
77
+ */
78
+ connect?: () => void | Promise<unknown>;
79
+ /** Disconnect one device listed by `devices()`. Used by the shell's top strip. */
80
+ disconnect?: (deviceId: string) => void | Promise<unknown>;
81
+ /**
82
+ * Called when the module loses the device lease.
83
+ *
84
+ * It MUST do three things: stop output, clear any "held down" aggregate
85
+ * state, and reject subsequent commands. Doing only the first is not
86
+ * enough — e.g. Chat's fire aggregation snapshots a baseline on the
87
+ * empty-to-nonempty edge; if revocation lands while someone is holding
88
+ * fire, the matching release message never arrives and strength stays at
89
+ * baseline+boost instead of falling back.
90
+ *
91
+ * It must NEVER be implemented as disconnect(). Agent and Voice clients
92
+ * run with autoReconnect; surrendering control by disconnecting lets the
93
+ * background module silently reconnect inside the GATT-disconnect event
94
+ * and steal the device back (the client still caches the BluetoothDevice
95
+ * reference, so reconnecting needs no user gesture) — while the new holder
96
+ * believes it has exclusive control.
97
+ */
98
+ onRevoke?: () => void | Promise<void>;
99
+ }
100
+ export interface StopAllResult {
101
+ /** Number of sessions a stop was attempted for. */
102
+ attempted: number;
103
+ /** Sessions that threw, with the error. The rest are stopped. */
104
+ failed: {
105
+ id: string;
106
+ error: unknown;
107
+ }[];
108
+ }
109
+ /** Register a module's device session. Returns an unregister function. */
110
+ export declare function registerSafetySession(session: SafetySession): () => void;
111
+ /** A registered module session, including its optional shell connect action. */
112
+ export declare function safetySessionById(id: string | null): SafetySession | null;
113
+ /** Modules that currently have an active device session. */
114
+ export declare function activeSafetySessions(): SafetySession[];
115
+ export declare function hasActiveSafetySession(): boolean;
116
+ /**
117
+ * All devices currently held, grouped by module.
118
+ *
119
+ * If a module's check throws, skip that module instead of aborting — one
120
+ * module's broken state read must not blank the whole device bar, which
121
+ * would also hide the stop button next to it.
122
+ */
123
+ export declare function allConnectedDevices(): {
124
+ sessionId: string;
125
+ label: string;
126
+ devices: DeviceSummary[];
127
+ }[];
128
+ /**
129
+ * Stop every registered session.
130
+ *
131
+ * allSettled, not all: one module throwing must not abort the others' stops
132
+ * — that is exactly the dangerous case (one device throws on a dropped
133
+ * connection while another keeps outputting).
134
+ */
135
+ export declare function stopAllSafetySessions(): Promise<StopAllResult>;
136
+ /**
137
+ * Hand the device lease to a module. All other modules lose control
138
+ * immediately and are told to stop.
139
+ *
140
+ * Revocation is best-effort: one module's onRevoke throwing must not abort
141
+ * the others' revocation — that is exactly the dangerous case (one module
142
+ * errors while another is still being remotely controlled).
143
+ */
144
+ export declare function grantDeviceLease(moduleId: string | null): Promise<void>;
145
+ /**
146
+ * Whether this module currently holds the device lease.
147
+ *
148
+ * Modules check this before *every* device command, not just to disable
149
+ * buttons — remote commands (other room members, AI) never pass through the
150
+ * UI.
151
+ */
152
+ export declare function hasDeviceLease(moduleId: string): boolean;
153
+ export declare function currentDeviceLease(): string | null;
154
+ /** Subscribe to registry changes (module mount/unmount, lease transfer). */
155
+ export declare function subscribeSafetySessions(listener: () => void): () => void;
156
+ //# sourceMappingURL=safety-bus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safety-bus.d.ts","sourceRoot":"","sources":["../src/safety-bus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,EAAE,EAAE,MAAM,CAAC;IACX,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mHAAmH;IACnH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oEAAoE;IACpE,QAAQ,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5D;AAED,MAAM,WAAW,aAAa;IAC5B,iEAAiE;IACjE,EAAE,EAAE,MAAM,CAAC;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,aAAa,EAAE,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,kFAAkF;IAClF,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3D;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,aAAa;IAC5B,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;CAC1C;AAmBD,0EAA0E;AAC1E,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,IAAI,CAWxE;AAED,gFAAgF;AAChF,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,aAAa,GAAG,IAAI,CAEzE;AAED,4DAA4D;AAC5D,wBAAgB,oBAAoB,IAAI,aAAa,EAAE,CAUtD;AAED,wBAAgB,sBAAsB,IAAI,OAAO,CAEhD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,IAAI;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B,EAAE,CAWF;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,aAAa,CAAC,CAcpE;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB7E;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CAElD;AAED,4EAA4E;AAC5E,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAGxE"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Safety bus: keeps "stop" reachable regardless of UI state.
3
+ *
4
+ * Each module registers its own emergency-stop function here, so the shell
5
+ * (or any orchestration layer) can stop *every* registered device session in
6
+ * one place, without knowing which modules exist or how each implements its
7
+ * stop.
8
+ *
9
+ * Why it exists: a module that is switched away from gets hidden (it stays
10
+ * mounted so BLE doesn't drop), which means its own stop button is
11
+ * unreachable while its device is *still outputting*. The shell needs a stop
12
+ * anchor that doesn't come and go with module visibility.
13
+ *
14
+ * Three design constraints:
15
+ * 1. `stopAll` must be best-effort — it must NOT abort because one session
16
+ * throws. When one module's BLE is already gone and throws, the other
17
+ * modules' devices are still outputting.
18
+ * 2. The registry is a Map, not an array: on hot reload / remount a module
19
+ * overwrites its entry by id instead of piling up stale closures —
20
+ * otherwise stopAll would invoke functions of unmounted modules.
21
+ * 3. No "remember who was outputting last" optimization. Stop is a
22
+ * low-frequency, high-stakes operation; stopping a few idle devices is
23
+ * fine, missing one because of a state-tracking bug is not.
24
+ */
25
+ const sessions = new Map();
26
+ const listeners = new Set();
27
+ /**
28
+ * The module currently holding the device lease.
29
+ *
30
+ * `null` means no module holds it — e.g. while sitting on the home screen.
31
+ * Then *no module may issue commands*, but devices stay connected and the
32
+ * stop button stays usable. This matters: surrendering control is not the
33
+ * same as disconnecting.
34
+ */
35
+ let leaseHolder = null;
36
+ function notify() {
37
+ for (const l of listeners)
38
+ l();
39
+ }
40
+ /** Register a module's device session. Returns an unregister function. */
41
+ export function registerSafetySession(session) {
42
+ sessions.set(session.id, session);
43
+ notify();
44
+ return () => {
45
+ // Delete only while still ours: after a remount the new session has
46
+ // overwritten the old one, and the old cleanup must not remove the new.
47
+ if (sessions.get(session.id) === session) {
48
+ sessions.delete(session.id);
49
+ notify();
50
+ }
51
+ };
52
+ }
53
+ /** A registered module session, including its optional shell connect action. */
54
+ export function safetySessionById(id) {
55
+ return id ? (sessions.get(id) ?? null) : null;
56
+ }
57
+ /** Modules that currently have an active device session. */
58
+ export function activeSafetySessions() {
59
+ return [...sessions.values()].filter((s) => {
60
+ try {
61
+ return s.isActive();
62
+ }
63
+ catch {
64
+ // If the check itself throws, treat as active — better to show one
65
+ // extra stop button than to miss one.
66
+ return true;
67
+ }
68
+ });
69
+ }
70
+ export function hasActiveSafetySession() {
71
+ return activeSafetySessions().length > 0;
72
+ }
73
+ /**
74
+ * All devices currently held, grouped by module.
75
+ *
76
+ * If a module's check throws, skip that module instead of aborting — one
77
+ * module's broken state read must not blank the whole device bar, which
78
+ * would also hide the stop button next to it.
79
+ */
80
+ export function allConnectedDevices() {
81
+ const out = [];
82
+ for (const s of sessions.values()) {
83
+ try {
84
+ const devices = s.devices?.().filter((d) => d.connected) ?? [];
85
+ if (devices.length)
86
+ out.push({ sessionId: s.id, label: s.label, devices });
87
+ }
88
+ catch {
89
+ // Skip this one; the rest proceed as normal.
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ /**
95
+ * Stop every registered session.
96
+ *
97
+ * allSettled, not all: one module throwing must not abort the others' stops
98
+ * — that is exactly the dangerous case (one device throws on a dropped
99
+ * connection while another keeps outputting).
100
+ */
101
+ export async function stopAllSafetySessions() {
102
+ const list = [...sessions.values()];
103
+ const results = await Promise.allSettled(
104
+ // The wrapper is required: if stop() throws *synchronously*, the
105
+ // exception escapes inside map and never reaches allSettled — exactly
106
+ // the dangerous case (one device throws on disconnect, none of the
107
+ // others get stopped). The unit test "one session throwing must not
108
+ // abort the others" guards this.
109
+ list.map((s) => Promise.resolve().then(() => s.stop())));
110
+ const failed = results.flatMap((r, i) => r.status === 'rejected' ? [{ id: list[i].id, error: r.reason }] : []);
111
+ return { attempted: list.length, failed };
112
+ }
113
+ /**
114
+ * Hand the device lease to a module. All other modules lose control
115
+ * immediately and are told to stop.
116
+ *
117
+ * Revocation is best-effort: one module's onRevoke throwing must not abort
118
+ * the others' revocation — that is exactly the dangerous case (one module
119
+ * errors while another is still being remotely controlled).
120
+ */
121
+ export async function grantDeviceLease(moduleId) {
122
+ if (leaseHolder === moduleId)
123
+ return;
124
+ const previous = leaseHolder;
125
+ leaseHolder = moduleId;
126
+ notify();
127
+ if (!previous)
128
+ return;
129
+ const losing = sessions.get(previous);
130
+ if (!losing?.onRevoke)
131
+ return;
132
+ try {
133
+ // `try { await f() }` already catches a synchronous throw — the call
134
+ // itself is inside the try. (stopAllSafetySessions needs the extra
135
+ // wrapper because its call happens inside `map`, outside allSettled —
136
+ // a different situation.)
137
+ await losing.onRevoke();
138
+ }
139
+ catch {
140
+ // If revocation fails, at least stop it — it no longer holds the lease,
141
+ // but its device may still be outputting.
142
+ try {
143
+ await losing.stop();
144
+ }
145
+ catch {
146
+ // Both paths failed: the global stop button is still reachable; that
147
+ // is the last line of defense.
148
+ }
149
+ }
150
+ }
151
+ /**
152
+ * Whether this module currently holds the device lease.
153
+ *
154
+ * Modules check this before *every* device command, not just to disable
155
+ * buttons — remote commands (other room members, AI) never pass through the
156
+ * UI.
157
+ */
158
+ export function hasDeviceLease(moduleId) {
159
+ return leaseHolder === moduleId;
160
+ }
161
+ export function currentDeviceLease() {
162
+ return leaseHolder;
163
+ }
164
+ /** Subscribe to registry changes (module mount/unmount, lease transfer). */
165
+ export function subscribeSafetySessions(listener) {
166
+ listeners.add(listener);
167
+ return () => listeners.delete(listener);
168
+ }
169
+ //# sourceMappingURL=safety-bus.js.map