super-dsh 0.1.0 → 0.1.2

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 (178) hide show
  1. package/agent-agy/bridge/agy_bridge.py +250 -0
  2. package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
  3. package/agent-agy/cordis.patch.yml +49 -0
  4. package/agent-agy/dist/adapter.d.ts +15 -0
  5. package/agent-agy/dist/adapter.js +42 -0
  6. package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
  7. package/agent-agy/dist/agent-preset-agy.js +169 -0
  8. package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
  9. package/agent-agy/dist/agent-preset-projection.js +33 -0
  10. package/agent-agy/dist/agent.d.ts +137 -0
  11. package/agent-agy/dist/agent.js +493 -0
  12. package/agent-agy/dist/agy-cli-client.d.ts +137 -0
  13. package/agent-agy/dist/agy-cli-client.js +301 -0
  14. package/agent-agy/dist/agy-client.d.ts +160 -0
  15. package/agent-agy/dist/agy-client.js +252 -0
  16. package/agent-agy/dist/agy-sessions.d.ts +24 -0
  17. package/agent-agy/dist/agy-sessions.js +116 -0
  18. package/agent-agy/dist/agy-store.d.ts +39 -0
  19. package/agent-agy/dist/agy-store.js +83 -0
  20. package/agent-agy/dist/inbox.d.ts +26 -0
  21. package/agent-agy/dist/inbox.js +53 -0
  22. package/agent-agy/dist/index.d.ts +80 -0
  23. package/agent-agy/dist/index.js +505 -0
  24. package/agent-agy/dist/models.d.ts +41 -0
  25. package/agent-agy/dist/models.js +74 -0
  26. package/agent-agy/dist/world-plugin.d.ts +9 -0
  27. package/agent-agy/dist/world-plugin.js +97 -0
  28. package/agent-agy/package.json +64 -0
  29. package/agent-claude/cordis.patch.yml +42 -0
  30. package/agent-claude/dist/adapter.js +62 -0
  31. package/agent-claude/dist/agent-preset-claude.js +169 -0
  32. package/agent-claude/dist/agent-preset-projection.js +30 -0
  33. package/agent-claude/dist/agent.js +1271 -0
  34. package/agent-claude/dist/claude-client.js +357 -0
  35. package/agent-claude/dist/claude-events.js +223 -0
  36. package/agent-claude/dist/claude-home.js +29 -0
  37. package/agent-claude/dist/commands.js +182 -0
  38. package/agent-claude/dist/content.js +134 -0
  39. package/agent-claude/dist/inbox.js +53 -0
  40. package/agent-claude/dist/index.js +555 -0
  41. package/agent-claude/dist/input-queue.js +64 -0
  42. package/agent-claude/dist/interaction.js +271 -0
  43. package/agent-claude/dist/models.js +158 -0
  44. package/agent-claude/dist/permission.js +120 -0
  45. package/agent-claude/dist/session-id.js +20 -0
  46. package/agent-claude/dist/session-map.js +165 -0
  47. package/agent-claude/dist/world-plugin.js +14 -0
  48. package/agent-claude/package.json +74 -0
  49. package/agent-codex/README.md +74 -0
  50. package/agent-codex/cordis.patch.yml +77 -0
  51. package/agent-codex/dist/adapter.js +55 -0
  52. package/agent-codex/dist/agent-preset-codex.js +171 -0
  53. package/agent-codex/dist/agent-preset-projection.js +37 -0
  54. package/agent-codex/dist/agent.js +1056 -0
  55. package/agent-codex/dist/app-home.js +65 -0
  56. package/agent-codex/dist/codex-client.js +469 -0
  57. package/agent-codex/dist/codex-events.js +297 -0
  58. package/agent-codex/dist/codex-store.js +190 -0
  59. package/agent-codex/dist/inbox.js +53 -0
  60. package/agent-codex/dist/index.js +605 -0
  61. package/agent-codex/dist/models.js +152 -0
  62. package/agent-codex/dist/permission.js +71 -0
  63. package/agent-codex/dist/session-map.js +170 -0
  64. package/agent-codex/dist/spike.js +46 -0
  65. package/agent-codex/dist/world-plugin.js +96 -0
  66. package/agent-codex/package.json +78 -0
  67. package/agent-hermes/README.md +144 -0
  68. package/agent-hermes/cordis.patch.yml +83 -0
  69. package/agent-hermes/dist/adapter.js +83 -0
  70. package/agent-hermes/dist/agent-preset-hermes.js +171 -0
  71. package/agent-hermes/dist/agent-preset-projection.js +34 -0
  72. package/agent-hermes/dist/agent.js +1068 -0
  73. package/agent-hermes/dist/hermes-client.js +953 -0
  74. package/agent-hermes/dist/hermes-events.js +289 -0
  75. package/agent-hermes/dist/hermes-store.js +158 -0
  76. package/agent-hermes/dist/inbox.js +53 -0
  77. package/agent-hermes/dist/index.js +541 -0
  78. package/agent-hermes/dist/models.js +324 -0
  79. package/agent-hermes/dist/permission.js +60 -0
  80. package/agent-hermes/dist/world-plugin.js +101 -0
  81. package/agent-hermes/package.json +76 -0
  82. package/agent-hub/cordis.patch.yml +12 -0
  83. package/agent-hub/dist/agent-roster.d.ts +34 -0
  84. package/agent-hub/dist/agent-roster.js +27 -0
  85. package/agent-hub/dist/carrier.d.ts +43 -0
  86. package/agent-hub/dist/carrier.js +241 -0
  87. package/agent-hub/dist/client-shim.d.ts +28 -0
  88. package/agent-hub/dist/client-shim.js +251 -0
  89. package/agent-hub/dist/envelope.d.ts +54 -0
  90. package/agent-hub/dist/envelope.js +92 -0
  91. package/agent-hub/dist/gateway.d.ts +28 -0
  92. package/agent-hub/dist/gateway.js +18 -0
  93. package/agent-hub/dist/index-pass.d.ts +22 -0
  94. package/agent-hub/dist/index-pass.js +47 -0
  95. package/agent-hub/dist/index.d.ts +29 -0
  96. package/agent-hub/dist/index.js +29 -0
  97. package/agent-hub/dist/labels.d.ts +45 -0
  98. package/agent-hub/dist/labels.js +83 -0
  99. package/agent-hub/dist/ownership.d.ts +37 -0
  100. package/agent-hub/dist/ownership.js +54 -0
  101. package/agent-hub/dist/roster.d.ts +28 -0
  102. package/agent-hub/dist/roster.js +38 -0
  103. package/agent-hub/dist/rpc.d.ts +38 -0
  104. package/agent-hub/dist/rpc.js +52 -0
  105. package/agent-hub/dist/spawn-world.d.ts +46 -0
  106. package/agent-hub/dist/spawn-world.js +72 -0
  107. package/agent-hub/dist/targets.d.ts +33 -0
  108. package/agent-hub/dist/targets.js +30 -0
  109. package/agent-hub/dist/world-entry.d.ts +31 -0
  110. package/agent-hub/dist/world-entry.js +51 -0
  111. package/agent-hub/dist/world-host.d.ts +69 -0
  112. package/agent-hub/dist/world-host.js +44 -0
  113. package/agent-hub/dist/world-join.d.ts +16 -0
  114. package/agent-hub/dist/world-join.js +122 -0
  115. package/agent-hub/dist/world-mount.d.ts +52 -0
  116. package/agent-hub/dist/world-mount.js +52 -0
  117. package/agent-hub/dist/world-mux.d.ts +72 -0
  118. package/agent-hub/dist/world-mux.js +224 -0
  119. package/agent-hub/dist/world-provision.d.ts +20 -0
  120. package/agent-hub/dist/world-provision.js +122 -0
  121. package/agent-hub/dist/world-web-server.d.ts +162 -0
  122. package/agent-hub/dist/world-web-server.js +248 -0
  123. package/agent-hub/lib/client/index.js +242 -0
  124. package/agent-hub/package.json +65 -0
  125. package/agent-omp/README.md +77 -0
  126. package/agent-omp/cordis.patch.yml +92 -0
  127. package/agent-omp/dist/adapter.js +36 -0
  128. package/agent-omp/dist/agent-preset-omp.js +171 -0
  129. package/agent-omp/dist/agent-preset-projection.js +36 -0
  130. package/agent-omp/dist/agent.js +1145 -0
  131. package/agent-omp/dist/app-home.js +86 -0
  132. package/agent-omp/dist/discovery.js +116 -0
  133. package/agent-omp/dist/inbox.js +53 -0
  134. package/agent-omp/dist/index.js +574 -0
  135. package/agent-omp/dist/knobs.js +50 -0
  136. package/agent-omp/dist/lazy-rpc.js +161 -0
  137. package/agent-omp/dist/mobile/gesture.js +148 -0
  138. package/agent-omp/dist/mobile/zoom-guard.js +294 -0
  139. package/agent-omp/dist/mobile-boot.js +38 -0
  140. package/agent-omp/dist/models.js +299 -0
  141. package/agent-omp/dist/omp-cli.js +147 -0
  142. package/agent-omp/dist/omp-disk-discovery.js +178 -0
  143. package/agent-omp/dist/omp-store.js +518 -0
  144. package/agent-omp/dist/pairing.js +45 -0
  145. package/agent-omp/dist/permission.js +96 -0
  146. package/agent-omp/dist/protocol.js +13 -0
  147. package/agent-omp/dist/rpc-types.js +1 -0
  148. package/agent-omp/dist/sdk-client.js +378 -0
  149. package/agent-omp/dist/sidecar-client.js +195 -0
  150. package/agent-omp/dist/store/adapter.js +47 -0
  151. package/agent-omp/dist/store/db.js +210 -0
  152. package/agent-omp/dist/store/index.js +59 -0
  153. package/agent-omp/dist/store/reconcile.js +51 -0
  154. package/agent-omp/dist/store/schema.js +40 -0
  155. package/agent-omp/dist/world-plugin.js +96 -0
  156. package/agent-omp/package.json +97 -0
  157. package/agent-omp/sidecar/main.ts +481 -0
  158. package/agent-pi/README.md +67 -0
  159. package/agent-pi/cordis.patch.yml +80 -0
  160. package/agent-pi/dist/adapter.js +53 -0
  161. package/agent-pi/dist/agent-preset-pi.js +169 -0
  162. package/agent-pi/dist/agent-preset-projection.js +29 -0
  163. package/agent-pi/dist/agent.js +949 -0
  164. package/agent-pi/dist/inbox.js +53 -0
  165. package/agent-pi/dist/index.js +595 -0
  166. package/agent-pi/dist/knobs.js +24 -0
  167. package/agent-pi/dist/models.js +200 -0
  168. package/agent-pi/dist/permission.js +66 -0
  169. package/agent-pi/dist/pi-client.js +224 -0
  170. package/agent-pi/dist/pi-events.js +155 -0
  171. package/agent-pi/dist/pi-home.js +55 -0
  172. package/agent-pi/dist/safe-json.js +25 -0
  173. package/agent-pi/dist/session-map.js +119 -0
  174. package/agent-pi/dist/world-plugin.js +97 -0
  175. package/agent-pi/package.json +77 -0
  176. package/cordis.patch.yml +22 -19
  177. package/package.json +88 -25
  178. package/client.js +0 -4
@@ -0,0 +1,161 @@
1
+ import { OmpSdkClient } from "./sdk-client.js";
2
+ /** Synthetic state for a not-yet-spawned child: quiescent by definition. */
3
+ const IDLE_STATE = { isStreaming: false };
4
+ export class LazyOmpRpc {
5
+ #spawnArgs;
6
+ #cwd;
7
+ #client;
8
+ #spawn;
9
+ #closed = false;
10
+ #eventListeners = new Set();
11
+ #titleListeners = new Set();
12
+ #failureListeners = new Set();
13
+ #pendingModel;
14
+ #onSpawned;
15
+ constructor(spawnArgs, cwd) {
16
+ this.#spawnArgs = spawnArgs;
17
+ this.#cwd = cwd;
18
+ }
19
+ /**
20
+ * Bridge hook: runs once when the child first exists (the caller writes the
21
+ * index row + supervisor held-baseline there — the create-time counterparts
22
+ * were deliberately skipped). Registered before or after the spawn; both
23
+ * orders fire exactly once.
24
+ */
25
+ onSpawned(handler) {
26
+ this.#onSpawned = handler;
27
+ if (this.#client !== undefined)
28
+ handler(this.#client);
29
+ }
30
+ get spawned() {
31
+ return this.#client !== undefined;
32
+ }
33
+ /** Spawn once; concurrent and later callers share the same child. */
34
+ #ensure() {
35
+ if (this.#client !== undefined)
36
+ return Promise.resolve(this.#client);
37
+ if (this.#spawn === undefined) {
38
+ this.#spawn = OmpSdkClient.spawn(this.#spawnArgs, this.#cwd).then((client) => {
39
+ // A close() that raced the in-flight spawn wins: dispose immediately.
40
+ if (this.#closed) {
41
+ client.close();
42
+ return client;
43
+ }
44
+ this.#client = client;
45
+ for (const listener of this.#eventListeners)
46
+ client.on(listener);
47
+ for (const listener of this.#titleListeners)
48
+ client.onTitle(listener);
49
+ for (const listener of this.#failureListeners)
50
+ client.onFailure(listener);
51
+ this.#onSpawned?.(client);
52
+ return client;
53
+ }).catch((error) => {
54
+ // Allow a later dispatch to retry the spawn.
55
+ this.#spawn = undefined;
56
+ throw error;
57
+ });
58
+ }
59
+ return this.#spawn;
60
+ }
61
+ on(listener) {
62
+ this.#eventListeners.add(listener);
63
+ if (this.#client !== undefined)
64
+ this.#client.on(listener);
65
+ return () => {
66
+ this.#eventListeners.delete(listener);
67
+ };
68
+ }
69
+ /**
70
+ * Title frames must survive the deferred spawn: OMP generates the title from
71
+ * the first prompt, which is exactly the moment this RPC materializes, so a
72
+ * listener registered before then would otherwise miss the only frame.
73
+ */
74
+ onTitle(listener) {
75
+ this.#titleListeners.add(listener);
76
+ if (this.#client !== undefined)
77
+ this.#client.onTitle(listener);
78
+ return () => {
79
+ this.#titleListeners.delete(listener);
80
+ };
81
+ }
82
+ onFailure(listener) {
83
+ this.#failureListeners.add(listener);
84
+ if (this.#client !== undefined)
85
+ this.#client.onFailure(listener);
86
+ return () => {
87
+ this.#failureListeners.delete(listener);
88
+ };
89
+ }
90
+ async prompt(message) {
91
+ const client = await this.#ensure();
92
+ // Apply a pre-spawn model selection now, ahead of the prompt (the sidecar
93
+ // records it as the session's model_change, like a pre-turn set_model).
94
+ const pending = this.#pendingModel;
95
+ if (pending !== undefined) {
96
+ this.#pendingModel = undefined;
97
+ await client.setModel(pending.provider, pending.modelId).catch(() => { });
98
+ }
99
+ return client.prompt(message);
100
+ }
101
+ async followUp(message) {
102
+ return (await this.#ensure()).followUp(message);
103
+ }
104
+ async steer(message) {
105
+ return (await this.#ensure()).steer(message);
106
+ }
107
+ async compact(instructions) {
108
+ return (await this.#ensure()).compact(instructions);
109
+ }
110
+ async contextUsage() {
111
+ if (this.#client === undefined)
112
+ return undefined;
113
+ return this.#client.contextUsage();
114
+ }
115
+ async abort() {
116
+ if (this.#client === undefined)
117
+ return undefined;
118
+ return this.#client.abort();
119
+ }
120
+ async ensureStarted() {
121
+ await this.#ensure();
122
+ }
123
+ async getState() {
124
+ if (this.#client === undefined)
125
+ return IDLE_STATE;
126
+ return this.#client.getState();
127
+ }
128
+ async getSubagents() {
129
+ if (this.#client === undefined)
130
+ return [];
131
+ return this.#client.getSubagents();
132
+ }
133
+ async getSessionStats() {
134
+ if (this.#client === undefined)
135
+ return {};
136
+ return this.#client.getSessionStats();
137
+ }
138
+ async getSystemPrompt() {
139
+ if (this.#client === undefined)
140
+ return undefined;
141
+ return this.#client.getSystemPrompt();
142
+ }
143
+ async setModel(provider, modelId) {
144
+ if (this.#client === undefined) {
145
+ // Park it: applied by prompt() immediately after the spawn.
146
+ this.#pendingModel = { provider, modelId };
147
+ return undefined;
148
+ }
149
+ return this.#client.setModel(provider, modelId);
150
+ }
151
+ sendRaw(command) {
152
+ if (this.#client !== undefined)
153
+ this.#client.sendRaw(command);
154
+ }
155
+ close() {
156
+ this.#closed = true;
157
+ const client = this.#client;
158
+ this.#client = undefined;
159
+ client?.close();
160
+ }
161
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Mobile swipe recognition (pure, unit-tested) — an EXACT port of the
3
+ * operator's own ui-layout patch (retired `z_dsh-alpha` checkout, commit
4
+ * `1706b81` "ui-layout: mobile swipe opens Better Sidebar right panel via
5
+ * its DOM toggle", `packages/client/ui-layout/src/client/AppFrame.tsx`)
6
+ * from patched-source form into plugin form, plus exactly ONE addition:
7
+ * an average-velocity gate (`swipeVelocityPxPerMs`, the 2026-09-02
8
+ * supplemental requirement — the only constant here without a source
9
+ * precedent; `0` disarms it and restores the source's behavior verbatim).
10
+ *
11
+ * Source semantics, preserved number-for-number:
12
+ * - **Start admission** (pointerdown): with BOTH panels closed, only the
13
+ * left band `x ≤ 120` (the source's X120) or the right three quarters
14
+ * `x ≥ viewport/4` may start a gesture — the center band stays inert so
15
+ * content swipes don't misfire. With a panel open, ANY position may
16
+ * start (the dismiss swipe naturally starts on the panel body).
17
+ * - **Firing** (pointermove, mid-gesture): the gesture fires DURING the
18
+ * drag as soon as `|dx| ≥ 40` AND `|dx| > |dy| × 1.3` (horizontal
19
+ * dominance over scroll intent) — then the drag is spent (one shot).
20
+ * Firing mid-gesture rather than at pointerup is what keeps a lazy
21
+ * swipe triggerable: a pointerup-time decision loses the pointer to the
22
+ * browser's scroll/pan heuristics (pointercancel) whenever the drag is
23
+ * slow, which reads to the user as "I must flick fast to trigger".
24
+ * - **State machine** (symmetric, from the source):
25
+ * left-swipe: close the sidebar if open, else open the right panel
26
+ * (Better Sidebar) when a session is current;
27
+ * right-swipe: close the right panel if open, else open the sidebar
28
+ * when collapsed.
29
+ * - **Band admission (2026-09-11 mobile wave)**: no own pixel cut-off. The
30
+ * feature is live on coarse pointers (phones/tablets) and wherever a
31
+ * panel is already in its narrow-viewport state — i.e. it follows the
32
+ * upstream layout STATE, never a threshold we invented. Upstream may
33
+ * move their auto-collapse boundary (1024 today) freely; we track the
34
+ * resulting state attributes.
35
+ *
36
+ * Both panel reads address OFFICIAL upstream surfaces ONLY (hard rule,
37
+ * 2026-09-11): the left sidebar through the layout service
38
+ * (`ctx.layout.toggleSidebar()`) and AppFrame's `[data-sidebar-collapsed]`;
39
+ * the right panel through the official 0.1.5 controls — expand
40
+ * `[data-sidebar-right-expand]` (conversation header corner) and toggle
41
+ * `[data-sidebar-right-toggle]` (dock chrome) — with its open state read
42
+ * off AppFrame's `[data-rightbar-collapsed]`. Third-party plugin DOM is
43
+ * never a state input again (the retired Better Sidebar coupling poisoned
44
+ * the whole state machine once that plugin was removed).
45
+ *
46
+ * No DOM here: these are pure predicates over gesture facts plus the
47
+ * panel state the caller reads off live DOM.
48
+ *
49
+ * @module omp-web/mobile/gesture
50
+ */
51
+ /** Resolved defaults — the `1706b81` constants (120 / viewport÷4 / 40 / 1.3)
52
+ * plus the one addition, the velocity gate. A slow text-selection drag runs
53
+ * ~0.13 px/ms; 0.15 px/ms filters those while any comfortable sweep (0.4
54
+ * px/ms and up) passes.
55
+ */
56
+ export const DEFAULT_SWIPE_THRESHOLDS = {
57
+ swipeDistancePx: 40,
58
+ dominanceRatio: 1.3,
59
+ leftEdgeBandPx: 120,
60
+ rightZoneRatio: 0.25,
61
+ swipeVelocityPxPerMs: 0.15,
62
+ };
63
+ /**
64
+ * Whether a pointerdown may START a gesture — the source's pointerdown
65
+ * gate, verbatim: with both panels closed, only the left band (X120) or
66
+ * the right three quarters of the screen admit; with a panel open, any
67
+ * position admits (that swipe is a dismiss).
68
+ *
69
+ * @param x - pointerdown client X.
70
+ * @param viewportWidth - viewport width at gesture time (px).
71
+ * @param panels - panel state at pointerdown.
72
+ * @param thresholds - resolved recognition thresholds.
73
+ * @returns `true` when the origin is admitted into tracking.
74
+ */
75
+ export function admitsSwipeStart(x, viewportWidth, panels, thresholds) {
76
+ if (!(panels.leftCollapsed && !panels.rightOpen))
77
+ return true;
78
+ return x <= thresholds.leftEdgeBandPx || x >= viewportWidth * thresholds.rightZoneRatio;
79
+ }
80
+ /**
81
+ * Classify one in-flight drag into a panel action — the source's
82
+ * pointermove decision, verbatim, plus the velocity gate. Callers fire
83
+ * ONCE (then drop the drag): a `null` here means "not yet / not this
84
+ * gesture", and the same drag may classify on a LATER move event as its
85
+ * displacement or velocity grows.
86
+ *
87
+ * @param progress - the drag's displacement and elapsed time so far.
88
+ * @param panels - panel state read live at this event.
89
+ * @param sessionLive - whether a session is current (gates opening the
90
+ * right panel, mirroring the source's `detailsSession !== undefined`).
91
+ * @param thresholds - resolved recognition thresholds.
92
+ * @returns the action the drag maps to, or `null` when no panel admits
93
+ * it yet.
94
+ */
95
+ export function classifySwipeProgress(progress, panels, sessionLive, thresholds) {
96
+ const adx = Math.abs(progress.dx);
97
+ const ady = Math.abs(progress.dy);
98
+ if (adx < thresholds.swipeDistancePx)
99
+ return null;
100
+ if (adx <= ady * thresholds.dominanceRatio)
101
+ return null;
102
+ if (thresholds.swipeVelocityPxPerMs > 0) {
103
+ if (progress.dtMs <= 0)
104
+ return null;
105
+ if (adx / progress.dtMs < thresholds.swipeVelocityPxPerMs)
106
+ return null;
107
+ }
108
+ if (progress.dx < 0) {
109
+ // Left swipe: close the sidebar if open, otherwise open the right
110
+ // panel when a session is current.
111
+ if (!panels.leftCollapsed)
112
+ return 'close-left';
113
+ if (sessionLive && !panels.rightOpen)
114
+ return 'open-right';
115
+ return null;
116
+ }
117
+ // Right swipe: close the right panel if open, otherwise open the
118
+ // sidebar when collapsed.
119
+ if (panels.rightOpen)
120
+ return 'close-right';
121
+ if (panels.leftCollapsed)
122
+ return 'open-left';
123
+ return null;
124
+ }
125
+ /**
126
+ * Merge the host-injected page config over the defaults. An absent config
127
+ * (host feature off, or an old cached page) resolves to enabled=false — the
128
+ * client half is inert unless the host opted the page in.
129
+ *
130
+ * @param pageConfig - `window.__OMP_WEB_MOBILE__` as left by the boot script.
131
+ * @returns resolved thresholds plus the enable flag.
132
+ */
133
+ export function resolveMobileConfig(pageConfig) {
134
+ if (pageConfig === undefined || pageConfig.enabled !== true) {
135
+ return { enabled: false, ...DEFAULT_SWIPE_THRESHOLDS };
136
+ }
137
+ const n = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
138
+ // The velocity gate alone accepts 0 (disarm) as a configured value.
139
+ const v = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
140
+ return {
141
+ enabled: true,
142
+ swipeDistancePx: n(pageConfig.swipeDistancePx, DEFAULT_SWIPE_THRESHOLDS.swipeDistancePx),
143
+ dominanceRatio: n(pageConfig.dominanceRatio, DEFAULT_SWIPE_THRESHOLDS.dominanceRatio),
144
+ leftEdgeBandPx: n(pageConfig.leftEdgeBandPx, DEFAULT_SWIPE_THRESHOLDS.leftEdgeBandPx),
145
+ rightZoneRatio: n(pageConfig.rightZoneRatio, DEFAULT_SWIPE_THRESHOLDS.rightZoneRatio),
146
+ swipeVelocityPxPerMs: v(pageConfig.swipeVelocityPxPerMs, DEFAULT_SWIPE_THRESHOLDS.swipeVelocityPxPerMs),
147
+ };
148
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * iOS focus auto-zoom suppression — pure decision core (host half, change
3
+ * `2026-09-03-ios-focus-zoom-suppression`).
4
+ *
5
+ * iOS Safari auto-zooms the page (~115–120%) whenever ANY focusable control
6
+ * with a computed font-size below 16px receives focus; DSH's input surfaces
7
+ * are all 13–14px, so every focus on an iPhone trips it. Solution B (user
8
+ * ruling, 2026-09-03): rewrite the viewport meta early — appending
9
+ * `maximum-scale=1, user-scalable=no` — which suppresses the focus zoom on
10
+ * iOS 10+ WITHOUT disabling pinch zoom (Discourse PR #30877 empirics).
11
+ *
12
+ * v0.2.5 refinement (change `2026-09-03-zoomguard-standalone-font-floor`):
13
+ * that empiric holds only IN-BROWSER. Standalone (Add to Home Screen) iOS
14
+ * HONORS `user-scalable=no` — the meta rewrite kills pinch there, and the
15
+ * user's primary usage IS the PWA (real-device datum, 2026-09-03). The guard
16
+ * therefore forks by display mode: browser keeps the meta rewrite untouched;
17
+ * standalone never touches the meta at all and instead injects a 16px
18
+ * font-floor stylesheet (the font-size mechanism kills the focus zoom while
19
+ * pinch stays fully engine-available).
20
+ *
21
+ * The five exported predicates here are the entire decision surface:
22
+ * everything else (state machine, resize re-evaluation, stock-meta
23
+ * reconciliation) lives in the boot script section {@link buildZoomGuardSection}
24
+ * emits. That section embeds these functions via `Function.prototype.toString`
25
+ * — the unit-tested source IS the shipped source, byte for byte — which
26
+ * constrains their shape:
27
+ *
28
+ * - **ES5 syntax only** (the inline head script targets ancient parsers):
29
+ * `var`, function declarations, string concatenation; no arrows, no
30
+ * let/const, no template literals, no destructuring.
31
+ * - **Fully self-contained bodies**: no module-scope identifiers, no helper
32
+ * calls, no shared constants. A bundler may rename the declaration, but a
33
+ * renamed free reference would be a `ReferenceError` on the page. The
34
+ * eval-style tests in `test/web-trust.spec.ts` execute the real generated
35
+ * script against a stub DOM, so any such leak fails the suite.
36
+ *
37
+ * @module omp-web/mobile/zoom-guard
38
+ */
39
+ /** The two zoom tokens the guard merges into the stock viewport content. */
40
+ export const ZOOM_GUARD_TOKENS = {
41
+ 'maximum-scale': '1',
42
+ 'user-scalable': 'no',
43
+ };
44
+ /**
45
+ * Whether a UA string + touch-capability pair is an iOS-class browser
46
+ * (design D2, mirroring Discourse `capabilities.isIOS`): iPhone/iPod/iPad,
47
+ * plus iPadOS 13+ desktop-Safari masquerade (`Macintosh` UA with a
48
+ * multi-touch digitizer). Deliberately NOT `pointer:coarse` — Android Chrome
49
+ * never focus-zooms and `maximum-scale` has historical side effects there,
50
+ * so the gate is tightened to the iOS family. UA-shape drift degrades
51
+ * benignly: a novel UA simply reads as non-iOS and no rewriting happens.
52
+ *
53
+ * @param ua - `navigator.userAgent`.
54
+ * @param maxTouchPoints - `navigator.maxTouchPoints` (0 when absent).
55
+ * @returns `true` for iOS-class browsers.
56
+ */
57
+ export function isIOSClassUA(ua, maxTouchPoints) {
58
+ return /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && maxTouchPoints > 1);
59
+ }
60
+ /**
61
+ * Token-level merge of viewport directives into a viewport meta `content`
62
+ * string (design D4): existing tokens keep their positions, a token whose key
63
+ * (compared case-insensitively) is in `tokens` is REPLACED by the configured
64
+ * key=value, and configured keys not present are APPENDED in insertion order.
65
+ * Idempotent by construction — merging always re-derives from the input, so
66
+ * repeated evaluation cannot accumulate duplicates.
67
+ *
68
+ * mergeViewportTokens('width=device-width, initial-scale=1', T)
69
+ * → 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'
70
+ * mergeViewportTokens('width=device-width, maximum-scale=5', T)
71
+ * → 'width=device-width, maximum-scale=1, user-scalable=no' // same-key override, position kept
72
+ *
73
+ * @param content - the stock (or stock-equivalent) content string.
74
+ * @param tokens - the directives to merge, keyed lowercase.
75
+ * @returns the merged content string.
76
+ */
77
+ export function mergeViewportTokens(content, tokens) {
78
+ var out = [];
79
+ var seen = {};
80
+ var parts = content.split(',');
81
+ for (var i = 0; i < parts.length; i++) {
82
+ var part = parts[i];
83
+ var raw = (part === undefined ? '' : part).replace(/^\s+|\s+$/g, '');
84
+ if (raw === '')
85
+ continue;
86
+ var eq = raw.indexOf('=');
87
+ var key = eq < 0 ? raw : raw.slice(0, eq).replace(/^\s+|\s+$/g, '');
88
+ var hit = null;
89
+ for (var k in tokens) {
90
+ if (tokens.hasOwnProperty(k) && k.toLowerCase() === key.toLowerCase()) {
91
+ hit = k;
92
+ break;
93
+ }
94
+ }
95
+ if (hit === null) {
96
+ out[out.length] = raw;
97
+ continue;
98
+ }
99
+ out[out.length] = hit + '=' + tokens[hit];
100
+ seen[hit] = true;
101
+ }
102
+ for (var j in tokens) {
103
+ if (tokens.hasOwnProperty(j) && !seen[j])
104
+ out[out.length] = j + '=' + tokens[j];
105
+ }
106
+ return out.join(', ');
107
+ }
108
+ /**
109
+ * The double gate (design D2/D3 + the `zoomGuard` config): rewrite applies
110
+ * only on an iOS-class browser AND a narrow viewport AND a non-`off`
111
+ * `zoomGuard`. An absent payload (or absent key) means the `'meta'` default —
112
+ * the same default-on posture as the mobile leg itself — so only an explicit
113
+ * `'off'` disarms the guard. Unknown non-`off` values read as `'meta'`
114
+ * (schema validation makes them unreachable in production; benign here).
115
+ *
116
+ * @param config - the `__OMP_WEB_MOBILE__` payload (only `zoomGuard` is read).
117
+ * @param isIOS - {@link isIOSClassUA} verdict for this page load.
118
+ * @param isNarrow - `matchMedia('(max-width: breakpoint-0.02px)')` verdict.
119
+ * @returns `true` when the viewport rewrite should be applied.
120
+ */
121
+ export function shouldApplyZoomGuard(config, isIOS, isNarrow) {
122
+ if (!isIOS || !isNarrow)
123
+ return false;
124
+ if (!config)
125
+ return true;
126
+ return config.zoomGuard !== 'off';
127
+ }
128
+ /**
129
+ * Standalone display-mode verdict (design D4 of change
130
+ * `2026-09-03-zoomguard-standalone-font-floor`): standalone PWAs HONOR
131
+ * `user-scalable=no` (the real-device pinch regression that motivated the
132
+ * display-mode split), so they must never enter the meta-rewrite path. Two
133
+ * sources, strict-`true` OR: the `(display-mode: standalone)` media query
134
+ * (primary) and legacy `navigator.standalone` (older iOS Safari exposes only
135
+ * the latter). Anything else — `undefined` (engine without the source),
136
+ * `false`, or truthy junk — reads as NOT standalone.
137
+ *
138
+ * @param mqMatchesStandalone - `matchMedia('(display-mode: standalone)').matches`.
139
+ * @param navStandalone - `navigator.standalone === true`.
140
+ * @returns `true` in standalone display mode (either source).
141
+ */
142
+ export function isStandaloneDisplay(mqMatchesStandalone, navStandalone) {
143
+ return mqMatchesStandalone === true || navStandalone === true;
144
+ }
145
+ /**
146
+ * The 16px font floor stylesheet (design D3): iOS auto-zooms on focus into
147
+ * ANY control whose computed font-size is below 16px, so in standalone mode
148
+ * — where the meta rewrite is off the table (pinch must stay
149
+ * engine-available) — the floor IS the suppression. Scoped by the same
150
+ * breakpoint band the meta leg uses and derived the same way
151
+ * (`breakpoint - 0.02`: 768 → 767.98, 900 → 899.98); `!important` beats the
152
+ * 13/14px CSS module rules and the body inline var chain;
153
+ * `[contenteditable="true"]` matches only the editable state (React
154
+ * serializes `contentEditable={bool}` to the literal attribute value
155
+ * "true"/"false").
156
+ *
157
+ * @param breakpointPx - the configured `mobile.breakpoint` (raw — this
158
+ * function derives the `-0.02` band itself, do NOT pre-subtract).
159
+ * @returns the complete stylesheet text.
160
+ */
161
+ export function buildFontFloorCss(breakpointPx) {
162
+ var bp = breakpointPx - 0.02;
163
+ return '@media (max-width:' + bp + 'px){ input,textarea,select,[contenteditable="true"]{ font-size:16px !important } }';
164
+ }
165
+ /**
166
+ * Build the boot script's zoomGuard section: the five decision predicates
167
+ * embedded via `toString()` plus the DOM state machine that applies them.
168
+ * The machine implements design D3/D4 with one discovered refinement —
169
+ *
170
+ * **The standalone fork (2026-09-03, change
171
+ * `2026-09-03-zoomguard-standalone-font-floor`).** Real-device datum:
172
+ * standalone (Add to Home Screen) iOS HONORS `user-scalable=no`, so the
173
+ * meta rewrite that is benign in-browser (pinch stays engine-controlled)
174
+ * kills pinch in the PWA — the user's primary surface. The section
175
+ * therefore forks right after the iOS gate, BEFORE any meta machinery: a
176
+ * one-shot startup verdict (display-mode never flips mid-session) via the
177
+ * `(display-mode: standalone)` MQ or legacy `navigator.standalone` (D4).
178
+ * Standalone → inject the `ios-zoom-font-floor` style element into the
179
+ * head (16px floor, media-query-scoped to the same breakpoint band, D3)
180
+ * and RETURN — no provisional meta, no reconcile observer, no ladder
181
+ * timer, no listeners, byte-identical stock meta (D1). The width dimension
182
+ * lives inside the stylesheet's media query, so the injection itself is
183
+ * NOT width-gated: a wide standalone load still gets the element; the rule
184
+ * simply does not match above the breakpoint. Browser mode runs the v0.2.4
185
+ * machinery unchanged.
186
+ * **Why reconciliation exists.** The webserver splices head injections
187
+ * immediately after the opening `<head>` tag, i.e. BEFORE the stock
188
+ * `<meta name="viewport">` in upstream's `index.html`. At boot-script time
189
+ * the stock meta is therefore NOT yet parsed, so the "meta missing"
190
+ * branch is the NORMAL path on the served shell, not an edge case. A
191
+ * provisional meta is created right away (the timing requirement: the
192
+ * suppressive tokens are live before any application bundle can focus an
193
+ * input), and a MutationObserver watches for the parser to insert the stock
194
+ * meta; the moment it appears, the guard records the true stock content,
195
+ * rewrites the STOCK meta (token merge, D4's actual semantics — restore
196
+ * later writes these exact bytes back), and removes the provisional one —
197
+ * leaving a single-meta document instead of relying on engines'
198
+ * unspecified multi-meta merge behavior. Engines without MutationObserver
199
+ * keep the provisional meta for the page's lifetime (degraded but still
200
+ * suppressive); the observer is never started once the stock meta was
201
+ * found synchronously.
202
+ *
203
+ * **Why the re-evaluation ladder exists (2026-09-03 rework, lead CDP
204
+ * finding on the 4999 instance).** The boot script runs during head parsing,
205
+ * BEFORE the engine applies the stock viewport meta — the layout viewport at
206
+ * first evaluation is the pre-meta engine default (980px observed), so
207
+ * `mq.matches` reads false on an initial narrow load, and the narrowing that
208
+ * follows meta application does NOT reliably dispatch `resize` to the page
209
+ * during load (headless Chrome: never). A ~10ms poll ladder therefore
210
+ * re-evaluates until the guard IS applied, `document.readyState` reaches
211
+ * `'complete'`, or ~2s elapse — the first layout always precedes deferred
212
+ * scripts, so the ladder lands the guard before any application bundle can
213
+ * focus an input. Steady-state ticks are idempotent no-ops; a wide load
214
+ * idles to the readyState stop and leaves no timer behind (a pending tick
215
+ * is also cancelled the moment the guard lands). MediaQuery `change` events
216
+ * (with the legacy `addListener` fallback) form a second re-evaluation
217
+ * channel alongside window `resize`.
218
+ *
219
+ * State machine (ES5, zero dependencies, synchronous first evaluation):
220
+ * `meta` is the element under management (provisional or reconciled stock),
221
+ * `stock` its recorded stock content, `mine` whether we created it,
222
+ * `applied` the last applied gate verdict. `apply(on)` is idempotent per
223
+ * state (no-op when the verdict is unchanged); merges always re-derive
224
+ * from the recorded stock, never from the live content, so resize storms
225
+ * cannot accumulate tokens. Non-iOS pages never wire any listener.
226
+ *
227
+ * Emitted only when the mobile payload exists and `zoomGuard !== 'off'`.
228
+ *
229
+ * @returns the inline section text (truthy `var` declarations + IIFE).
230
+ */
231
+ export function buildZoomGuardSection() {
232
+ return [
233
+ `var ZI=${isIOSClassUA.toString()};`,
234
+ `var ZM=${mergeViewportTokens.toString()};`,
235
+ `var ZS=${shouldApplyZoomGuard.toString()};`,
236
+ `var ZD=${isStandaloneDisplay.toString()};`,
237
+ `var ZF=${buildFontFloorCss.toString()};`,
238
+ '(function(){',
239
+ 'var m=window.__OMP_WEB_MOBILE__;',
240
+ 'var ios=ZI(navigator.userAgent,(navigator.maxTouchPoints||0));',
241
+ 'if(!ios)return;',
242
+ "var bpx=(m&&typeof m.breakpoint==='number'&&m.breakpoint>0)?m.breakpoint:768;",
243
+ 'var bp=bpx-0.02;',
244
+ "var sa=ZD((window.matchMedia&&window.matchMedia('(display-mode: standalone)').matches),(navigator.standalone===true));",
245
+ "if(sa){var f=document.createElement('style');f.setAttribute('id','ios-zoom-font-floor');f.setAttribute('data-plugin','omp-web');f.setAttribute('data-plugin-css','omp-web/zoom-font-floor');f.textContent=ZF(bpx);headEl().appendChild(f);return}",
246
+ "var mq=(typeof window.matchMedia==='function')?window.matchMedia('(max-width:'+bp+'px)'):null;",
247
+ "var T={'maximum-scale':'1','user-scalable':'no'};",
248
+ 'var meta=null,stock=null,mine=false,applied=false,obs=null,lad=null,lN=0;',
249
+ "function findStock(){var s=document.getElementsByTagName('meta');for(var i=0;i<s.length;i++){var e=s[i];if(e!==meta&&(e.getAttribute('name')||'').toLowerCase()==='viewport')return e}return null}",
250
+ "function headEl(){return document.head||document.getElementsByTagName('head')[0]||document.documentElement}",
251
+ 'function stopWatch(){if(obs){obs.disconnect();obs=null}}',
252
+ 'function stopLadder(){if(lad){clearTimeout(lad);lad=null}}',
253
+ "function dropMine(){if(meta&&mine){if(meta.parentNode)meta.parentNode.removeChild(meta);meta=null;mine=false}}",
254
+ 'function apply(on){',
255
+ 'if(on===applied)return;',
256
+ 'applied=on;',
257
+ 'if(on)stopLadder();',
258
+ 'if(on){',
259
+ 'var el=findStock();',
260
+ "if(el){meta=el;mine=false;stock=el.getAttribute('content')||''}",
261
+ "else if(!meta){meta=document.createElement('meta');meta.setAttribute('name','viewport');mine=true;stock='';headEl().appendChild(meta)}",
262
+ "meta.setAttribute('content',ZM(stock,T));",
263
+ "if(mine&&typeof MutationObserver==='function'&&!obs){obs=new MutationObserver(check);obs.observe(document.documentElement,{childList:true,subtree:true})}",
264
+ '}else{',
265
+ 'stopWatch();',
266
+ 'if(!meta)return;',
267
+ 'if(mine)dropMine();',
268
+ "else meta.setAttribute('content',stock);", // restore = exact stock bytes (D4)
269
+ '}',
270
+ '}',
271
+ 'function check(){',
272
+ 'var el=findStock();',
273
+ 'if(!el)return;',
274
+ 'stopWatch();',
275
+ "stock=el.getAttribute('content')||'';",
276
+ "if(applied){dropMine();meta=el;mine=false;meta.setAttribute('content',ZM(stock,T))}",
277
+ 'else{dropMine();meta=null;stock=null}',
278
+ '}',
279
+ 'function re(){apply(ZS(m,true,mq?mq.matches:false))}',
280
+ 'function tick(){',
281
+ 'lad=null;',
282
+ 're();',
283
+ 'if(applied)return;',
284
+ 'lN++;',
285
+ "if(document.readyState==='complete'||lN>=200)return;",
286
+ 'lad=setTimeout(tick,10);',
287
+ '}',
288
+ 're();',
289
+ "window.addEventListener('resize',re);",
290
+ "if(mq&&mq.addEventListener)mq.addEventListener('change',re);else if(mq&&mq.addListener)mq.addListener(re);",
291
+ "if(!applied&&typeof setTimeout==='function')lad=setTimeout(tick,10);",
292
+ '})()',
293
+ ].join('');
294
+ }
@@ -0,0 +1,38 @@
1
+ import { buildZoomGuardSection } from './mobile/zoom-guard.js';
2
+ /**
3
+ * Build the boot script text. Pure: same constants in, same script out — the
4
+ * unit tests pin the shape (mobile global present, zoom-guard section present,
5
+ * wrapped in a non-throwing IIFE).
6
+ *
7
+ * @returns the script text.
8
+ */
9
+ export function buildMobileBootScript() {
10
+ const parts = ['(function(){try{'];
11
+ // Ship default ON: a bare page carries the mobile global (the client's
12
+ // `resolveMobileConfig` merges the threshold defaults over `enabled: true`).
13
+ parts.push(`window.__OMP_WEB_MOBILE__=${JSON.stringify({ enabled: true })};`);
14
+ // The zoom guard rides the mobile leg (default 'meta' mode): emitted always.
15
+ parts.push(buildZoomGuardSection());
16
+ parts.push('}catch(e){}})();');
17
+ return parts.join('');
18
+ }
19
+ /**
20
+ * Mount the boot-script injection: one `webserver/index-inject` listener
21
+ * pushing the rendered row. Conditional on the webServer service, so the
22
+ * plugin loads (with the feature dormant) in compositions without one.
23
+ * Fail-open: a push failure is logged, never thrown.
24
+ *
25
+ * @param ctx - host plugin context.
26
+ */
27
+ export function installMobileBootScript(ctx) {
28
+ ctx.inject(['webServer'], () => {
29
+ ctx.on('webserver/index-inject', (table) => {
30
+ try {
31
+ table.push({ kind: 'script', placement: 'head', text: buildMobileBootScript() });
32
+ }
33
+ catch (error) {
34
+ ctx.logger.warn(`omp-web: mobile boot script injection failed: ${String(error)}`);
35
+ }
36
+ });
37
+ });
38
+ }