oysterun 1.6.0-beta.2 → 1.6.0-beta.21

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 (242) hide show
  1. package/bin/oysterun.mjs +64 -17
  2. package/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
  3. package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
  4. package/dev/client/connectivity/completed-message-visibility.d.mts +4 -4
  5. package/dev/client/connectivity/completed-message-visibility.mjs +14 -11
  6. package/dev/client/connectivity/desktop-chat-sidebar-preference.mjs +57 -0
  7. package/dev/client/connectivity/host-connection-feedback.mjs +12 -1
  8. package/dev/client/connectivity/managed-host-browser.mjs +10 -0
  9. package/dev/client/connectivity/session-navigation-intent.mjs +189 -0
  10. package/dev/client/connectivity/session-transition-state.mjs +18 -6
  11. package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
  12. package/dev/client/web/index.html +5367 -609
  13. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js → ReactPrism-C6YiTZTC.js} +1 -1
  14. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js.map → ReactPrism-C6YiTZTC.js.map} +1 -1
  15. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js +6442 -0
  16. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js.map +1 -0
  17. package/dev/client/web-chat/dist/assets/SessionsSetupApp-Dx09Uz-6.css +1 -0
  18. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js +9 -0
  19. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js.map +1 -0
  20. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js +2 -0
  21. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js.map +1 -0
  22. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js → index-DCQFwgGf.js} +2 -2
  23. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js.map → index-DCQFwgGf.js.map} +1 -1
  24. package/dev/client/web-chat/dist/assets/matrix-entry-BV7G38kH.css +10 -0
  25. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js +594 -0
  26. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js.map +1 -0
  27. package/dev/client/web-chat/dist/assets/{pdf-ZjtalfgI.js → pdf-DN7oVvM6.js} +5 -5
  28. package/dev/client/web-chat/dist/assets/pdf-DN7oVvM6.js.map +1 -0
  29. package/dev/client/web-chat/dist/assets/session-notice-DlyODy1z.css +1 -0
  30. package/dev/client/web-chat/dist/assets/web-BlYuDlOV.js +1 -0
  31. package/dev/client/web-chat/dist/assets/{web-c70YaskK.js.map → web-BlYuDlOV.js.map} +1 -1
  32. package/dev/client/web-chat/dist/assets/web-Dabw6cJi.js +1 -0
  33. package/dev/client/web-chat/dist/assets/{web-BzHcZK2M.js.map → web-Dabw6cJi.js.map} +1 -1
  34. package/dev/client/web-chat/dist/index.html +1 -2
  35. package/docs/routec/dev/cloud_deplyments.md +5 -0
  36. package/docs/routec/dev/npm_native_dependency_install.md +6 -2
  37. package/docs/routec/dev/npmjs_publish.md +55 -19
  38. package/docs/routec/dev/p262_setup_input_file.md +117 -0
  39. package/docs/routec/host2_turn_started_idempotency_diagnostics.md +8 -7
  40. package/docs/routec/host_setup.md +39 -10
  41. package/docs/routec/oysterun_operation_interface_spec.md +78 -2
  42. package/docs/routec/provider_contract.md +35 -5
  43. package/host-service/README.md +36 -0
  44. package/host-service/adapters/claude-code-adapter.mjs +48 -5
  45. package/host-service/adapters/codex-app-server-adapter.mjs +320 -18
  46. package/host-service/adapters/debug-fixture-provider-adapter.mjs +102 -1
  47. package/host-service/adapters/debug-large-tool-spillover-provider-adapter.mjs +2 -0
  48. package/host-service/adapters/debug-p135-codex-replay-provider-adapter.mjs +2 -0
  49. package/host-service/adapters/debug-routec-structural-replay-provider-adapter.mjs +2 -0
  50. package/host-service/agent-config.mjs +16 -5
  51. package/host-service/agent-folder-availability.mjs +41 -0
  52. package/host-service/apns-complete-message-dispatcher.mjs +1 -0
  53. package/host-service/assets/product-skills/Oysterun/modules/oysterun-find-context/SKILL.md +24 -0
  54. package/host-service/assets/product-skills/Oysterun/modules/oysterun-session-chat/SKILL.md +94 -5
  55. package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +103 -0
  56. package/host-service/assets/product-skills/Oysterun/modules/oysterun-website/SKILL.md +7 -0
  57. package/host-service/assets/product-skills/oysterun-find-context/SKILL.md +24 -0
  58. package/host-service/assets/product-skills/oysterun-session-chat/SKILL.md +94 -5
  59. package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +103 -0
  60. package/host-service/assets/product-skills/oysterun-website/SKILL.md +7 -0
  61. package/host-service/callback-durable-store.mjs +354 -47
  62. package/host-service/callback-foundation.mjs +21 -1
  63. package/host-service/callback-host-integration.mjs +345 -30
  64. package/host-service/claude-acp-model-discovery.mjs +23 -18
  65. package/host-service/cli/assert-installer-reinstall-target.mjs +9 -0
  66. package/host-service/cli/installer-recovery-guidance.mjs +20 -0
  67. package/host-service/cli/prepare-restart-restore.mjs +2 -0
  68. package/host-service/cli/product-cli.mjs +34 -33
  69. package/host-service/cli/reconcile-initial-preflight.mjs +19 -0
  70. package/host-service/cli/recover-installer-lifecycle.mjs +24 -0
  71. package/host-service/cli/run-host-lifecycle.mjs +2 -0
  72. package/host-service/cli/run-staged-lifecycle-recovery.mjs +161 -0
  73. package/host-service/codex-model-discovery.mjs +23 -18
  74. package/host-service/complete-cli-uninstall.mjs +176 -0
  75. package/host-service/complete-message-notification-predicate.mjs +8 -6
  76. package/host-service/complete-message-notification-title.mjs +4 -0
  77. package/host-service/completed-session-unread.mjs +425 -42
  78. package/host-service/completed-turn-navigation.mjs +444 -144
  79. package/host-service/completed-turn-notification-observer.mjs +116 -0
  80. package/host-service/completed-turn-service.mjs +445 -0
  81. package/host-service/config.mjs +115 -9
  82. package/host-service/desired-open-session-state.mjs +331 -0
  83. package/host-service/fixtures/p218-completed-turn-notification.json +11 -0
  84. package/host-service/frp-agent.mjs +9 -9
  85. package/host-service/grok-acp-model-discovery.mjs +31 -33
  86. package/host-service/host-lifecycle-recovery.mjs +37 -0
  87. package/host-service/host-port-resolution.mjs +98 -0
  88. package/host-service/host-runtime-diagnostics.mjs +18 -1
  89. package/host-service/initial-preflight-recovery.mjs +71 -0
  90. package/host-service/installer-recovery-guidance.mjs +85 -0
  91. package/host-service/installer-reinstall-bindings.mjs +49 -0
  92. package/host-service/installer-reinstall-target.mjs +49 -0
  93. package/host-service/legacy-continuity-observation.mjs +33 -0
  94. package/host-service/lifecycle-compat-cli.mjs +13 -0
  95. package/host-service/lifecycle-compat-preload.mjs +89 -0
  96. package/host-service/lifecycle-compatibility.mjs +164 -0
  97. package/host-service/lifecycle-continuity-assets.mjs +222 -0
  98. package/host-service/lifecycle-continuity-probe.mjs +97 -0
  99. package/host-service/lifecycle-continuity-projection.mjs +29 -0
  100. package/host-service/lifecycle-continuity-runner.mjs +192 -0
  101. package/host-service/lifecycle-continuity.mjs +493 -0
  102. package/host-service/lifecycle-service-render-bundle.mjs +120 -0
  103. package/host-service/lifecycle-user-path.mjs +34 -0
  104. package/host-service/link-annotations.mjs +10 -0
  105. package/host-service/matrix-credential-issuer.mjs +32 -1
  106. package/host-service/matrix-event-correlator.mjs +166 -27
  107. package/host-service/matrix-facade.mjs +96 -19
  108. package/host-service/matrix-room-binding.mjs +24 -0
  109. package/host-service/matrix-transcript-read-adapter.mjs +17 -3
  110. package/host-service/model-discovery-guardian.mjs +40 -0
  111. package/host-service/model-discovery-process.mjs +141 -0
  112. package/host-service/onboard-lifecycle-status.mjs +28 -0
  113. package/host-service/onboard.mjs +479 -0
  114. package/host-service/package.json +2 -1
  115. package/host-service/portable-scheduler-definitions.mjs +28 -0
  116. package/host-service/product-runtime-host-origin.mjs +22 -0
  117. package/host-service/provider-model-params.mjs +14 -17
  118. package/host-service/provider-model-refresh-runner.mjs +124 -169
  119. package/host-service/provider-model-refresh-schedule.mjs +11 -0
  120. package/host-service/provider-resume-contract.mjs +9 -3
  121. package/host-service/restart-restore-state.mjs +25 -0
  122. package/host-service/retained-host-state-holder.mjs +57 -0
  123. package/host-service/routec-matrix-sqlite-store.mjs +155 -10
  124. package/host-service/routec-matrix-storage-adapter.mjs +208 -25
  125. package/host-service/routec-matrix-storage-paths.mjs +12 -0
  126. package/host-service/routec-request-delay-window.mjs +55 -0
  127. package/host-service/scheduler-acp-provider-runner.mjs +2 -1
  128. package/host-service/scheduler-direct-provider-runner.mjs +71 -9
  129. package/host-service/scheduler-run-timeout-policy.mjs +85 -0
  130. package/host-service/scheduler-service.mjs +96 -9
  131. package/host-service/scheduler-store.mjs +9 -2
  132. package/host-service/server.mjs +2202 -552
  133. package/host-service/service-definition-migration.mjs +10 -3
  134. package/host-service/session-collection-service.mjs +320 -0
  135. package/host-service/session-history.mjs +99 -15
  136. package/host-service/session-list-preview-service.mjs +87 -12
  137. package/host-service/session-list-projection-service.mjs +182 -0
  138. package/host-service/session-loop-definitions.mjs +702 -75
  139. package/host-service/session-manager.mjs +473 -38
  140. package/host-service/session-switcher-projection.mjs +11 -3
  141. package/host-service/session-tags.mjs +451 -0
  142. package/host-service/session-transcript-target.mjs +103 -0
  143. package/host-service/setup-input-file.mjs +91 -0
  144. package/host-service/setup-provider-model-defaults.mjs +53 -0
  145. package/host-service/setup.mjs +133 -101
  146. package/host-service/stopped-user-host-package.mjs +60 -0
  147. package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
  148. package/host-service/update-reminder-state.mjs +7 -0
  149. package/host-service/voice-input-receipt-store.mjs +60 -0
  150. package/package.json +1 -1
  151. package/static/oysterun-social-preview.png +0 -0
  152. package/static/site/assets/oysterun-social-preview.png +0 -0
  153. package/static/site/blog/en/index.html +17 -0
  154. package/static/site/blog/generated_manifest.json +9 -9
  155. package/static/site/blog/index.html +18 -0
  156. package/static/site/blog/voice-input-model-experiment/en/index.html +17 -0
  157. package/static/site/blog/voice-input-model-experiment/zh/index.html +17 -0
  158. package/static/site/blog/zh/index.html +17 -0
  159. package/static/site/docs/choose-and-recover-a-provider/en/index.html +17 -0
  160. package/static/site/docs/choose-and-recover-a-provider/zh/index.html +17 -0
  161. package/static/site/docs/configure-session-files-and-settings/en/index.html +17 -0
  162. package/static/site/docs/configure-session-files-and-settings/zh/index.html +17 -0
  163. package/static/site/docs/en/index.html +17 -0
  164. package/static/site/docs/generated_manifest.json +60 -60
  165. package/static/site/docs/host-authentication-and-connection/en/index.html +17 -0
  166. package/static/site/docs/host-authentication-and-connection/zh/index.html +17 -0
  167. package/static/site/docs/index.html +18 -0
  168. package/static/site/docs/install-and-use-a-skill/en/index.html +17 -0
  169. package/static/site/docs/install-and-use-a-skill/zh/index.html +17 -0
  170. package/static/site/docs/read-large-tool-results/en/index.html +17 -0
  171. package/static/site/docs/read-large-tool-results/zh/index.html +17 -0
  172. package/static/site/docs/recover-from-a-host-runtime-error/en/index.html +17 -0
  173. package/static/site/docs/recover-from-a-host-runtime-error/zh/index.html +17 -0
  174. package/static/site/docs/restore-work-after-a-host-restart/en/index.html +17 -0
  175. package/static/site/docs/restore-work-after-a-host-restart/zh/index.html +17 -0
  176. package/static/site/docs/send-images-and-media/en/index.html +17 -0
  177. package/static/site/docs/send-images-and-media/zh/index.html +17 -0
  178. package/static/site/docs/set-up-and-understand-a-host/en/index.html +17 -0
  179. package/static/site/docs/set-up-and-understand-a-host/zh/index.html +17 -0
  180. package/static/site/docs/understand-agent-activity/en/index.html +19 -2
  181. package/static/site/docs/understand-agent-activity/en/index.md +2 -2
  182. package/static/site/docs/understand-agent-activity/zh/index.html +19 -2
  183. package/static/site/docs/understand-agent-activity/zh/index.md +2 -2
  184. package/static/site/docs/use-the-oysterun-cli-safely/en/index.html +17 -0
  185. package/static/site/docs/use-the-oysterun-cli-safely/zh/index.html +17 -0
  186. package/static/site/docs/zh/index.html +17 -0
  187. package/static/site/en/index.html +18 -1
  188. package/static/site/en/index.md +1 -1
  189. package/static/site/faq/en/index.html +17 -0
  190. package/static/site/faq/index.html +18 -0
  191. package/static/site/faq/zh/index.html +17 -0
  192. package/static/site/getting-started/en/index.html +23 -1
  193. package/static/site/getting-started/en/index.md +7 -1
  194. package/static/site/getting-started/index.html +18 -0
  195. package/static/site/getting-started/zh/index.html +23 -1
  196. package/static/site/getting-started/zh/index.md +7 -1
  197. package/static/site/index.html +18 -0
  198. package/static/site/install.sh +676 -127
  199. package/static/site/overview/en/index.html +18 -1
  200. package/static/site/overview/en/index.md +2 -2
  201. package/static/site/overview/index.html +18 -0
  202. package/static/site/overview/zh/index.html +18 -1
  203. package/static/site/overview/zh/index.md +2 -2
  204. package/static/site/privacy/en/index.html +17 -0
  205. package/static/site/privacy/index.html +18 -0
  206. package/static/site/privacy/zh/index.html +17 -0
  207. package/static/site/zh/index.html +18 -1
  208. package/static/site/zh/index.md +1 -1
  209. package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +2 -2
  210. package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +2 -2
  211. package/static/site_source/downloads/install.sh +676 -127
  212. package/static/site_source/manifests/host_install.json +2 -10
  213. package/static/site_source/pages/getting-started/en.html +6 -1
  214. package/static/site_source/pages/getting-started/zh.html +6 -1
  215. package/static/site_source/pages/home/en.html +1 -1
  216. package/static/site_source/pages/home/zh.html +1 -1
  217. package/static/site_source/pages/home_main.html +3 -2
  218. package/static/site_source/pages/overview/en.html +1 -1
  219. package/static/site_source/pages/overview/zh.html +1 -1
  220. package/tool_scripts/install_oysterun_update.mjs +78 -13
  221. package/tool_scripts/oysterun_continuity_transaction.mjs +438 -0
  222. package/tool_scripts/oysterun_npm_package_manifest.mjs +32 -0
  223. package/tool_scripts/oysterun_release_service.sh +5 -0
  224. package/tool_scripts/oysterun_service_common.sh +59 -24
  225. package/tool_scripts/oysterun_setup_input.sh +97 -0
  226. package/tool_scripts/oysterun_update_restart_runner.mjs +19 -3
  227. package/tool_scripts/p233_legacy_bootstrap.mjs +254 -0
  228. package/tool_scripts/resolve_user_host_port.mjs +15 -0
  229. package/tool_scripts/restart_oysterun.sh +8 -2
  230. package/tool_scripts/stack_dashboard_credentials.mjs +2 -1
  231. package/tool_scripts/status_oysterun_launch_agents.sh +13 -1
  232. package/tool_scripts/sync_host_stack_config.mjs +9 -6
  233. package/tool_scripts/test_environment_remote_cloud_deprovision.mjs +419 -0
  234. package/tool_scripts/uninstall_oysterun_launch_agents.sh +3 -5
  235. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js +0 -129
  236. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js.map +0 -1
  237. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js +0 -2
  238. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js.map +0 -1
  239. package/dev/client/web-chat/dist/assets/index-NYnaSGBb.css +0 -1
  240. package/dev/client/web-chat/dist/assets/pdf-ZjtalfgI.js.map +0 -1
  241. package/dev/client/web-chat/dist/assets/web-BzHcZK2M.js +0 -1
  242. package/dev/client/web-chat/dist/assets/web-c70YaskK.js +0 -1
package/bin/oysterun.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, readFileSync, rmSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -13,9 +13,12 @@ import {
13
13
  runProductCli,
14
14
  } from "../host-service/cli/product-cli.mjs";
15
15
 
16
+ import { preflightCompleteUninstall, executeCompleteUninstall } from "../host-service/complete-cli-uninstall.mjs";
17
+
16
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
19
  const repoRoot = resolve(__dirname, "..");
18
20
  const setupScript = join(repoRoot, "host-service", "setup.mjs");
21
+ const onboardScript = join(repoRoot, "host-service", "onboard.mjs");
19
22
  const releaseServiceScript = join(
20
23
  repoRoot,
21
24
  "tool_scripts",
@@ -39,11 +42,11 @@ const serviceCommands = new Map([
39
42
  ["service:uninstall", "uninstall"],
40
43
  ]);
41
44
 
42
- function run(command, args) {
45
+ function run(command, args, { setupInput = false } = {}) {
43
46
  const result = spawnSync(command, args, {
44
47
  cwd: repoRoot,
45
48
  env: process.env,
46
- stdio: "inherit",
49
+ stdio: setupInput && process.env.OYSTERUN_SETUP_INPUT_FD !== undefined ? ["inherit", "inherit", "inherit", 3, 4] : "inherit",
47
50
  });
48
51
  if (result.error) {
49
52
  console.error(`oysterun: failed to run ${command}: ${result.error.message}`);
@@ -96,13 +99,16 @@ function parseUninstallArgs(args = []) {
96
99
  options.keepConfig = true;
97
100
  } else if (arg === "--confirm") {
98
101
  const value = args[index + 1];
99
- if (!value) {
102
+ if (!value || value.startsWith("--")) {
100
103
  throw new Error("--confirm requires a value");
101
104
  }
105
+ if (value !== "DELETE") throw new Error("config deletion requires --confirm DELETE exactly");
102
106
  options.confirm = value;
103
107
  index += 1;
104
108
  } else if (arg.startsWith("--confirm=")) {
105
- options.confirm = arg.slice("--confirm=".length);
109
+ const value = arg.slice("--confirm=".length);
110
+ if (value !== "DELETE") throw new Error("config deletion requires --confirm DELETE exactly");
111
+ options.confirm = value;
106
112
  } else if (arg === "--stack") {
107
113
  const value = args[index + 1];
108
114
  if (!value) {
@@ -195,6 +201,7 @@ Usage:
195
201
  oysterun
196
202
  oysterun --version
197
203
  oysterun setup [options]
204
+ oysterun onboard [--package-changed] [--channel latest|beta]
198
205
  oysterun show-qr
199
206
  oysterun uninstall [--confirm DELETE] [--keep-config] [--dry-run]
200
207
  oysterun service:start
@@ -213,6 +220,20 @@ Usage:
213
220
  Product modules:
214
221
  auth, sessions, chat, scheduler, mail, notifications, website, telegram
215
222
 
223
+ Chat transcript reads:
224
+ chat recent|messages|messages-around|search [--session-id <exact_id>]
225
+ A verified live Agent can read its Agent's stopped/history target without
226
+ starting it. History names are unsupported; use an exact retained Session ID.
227
+ chat messages --session-id <history_id> [--limit 20] [--next-batch <opaque>]
228
+ History page limits are 1..100; live messages retain --after-seq.
229
+ History read authority does not authorize send, loop, profile or lifecycle.
230
+
231
+ Session tags:
232
+ sessions start|resume|branch-resume [--tags 'first,second' | --clear-tags]
233
+ sessions profile update [--tags 'first,second' | --clear-tags]
234
+ --clear-tags creates or saves an untagged Session; empty --tags is invalid.
235
+ Omitted start tags inside a Session copy the authenticated parent's name.
236
+
216
237
  First run:
217
238
  If Host config is missing or incomplete, 'oysterun' enters setup.
218
239
  Release setup uses Oysterun Managed Tunnel by default.
@@ -236,8 +257,13 @@ Behavior:
236
257
  - Stops/removes managed Oysterun service state where available.
237
258
  - Preserves the config directory unless --confirm DELETE is supplied exactly.
238
259
  - Does not delete the Default Browse Root or agent folders.
239
- - Does not remove the global npm package; run npm uninstall -g oysterun after
240
- service/config cleanup if you want to remove the CLI itself.
260
+ - Exact --confirm DELETE also removes the same global CLI package that launched
261
+ this command using its matching Node/npm and explicit prefix.
262
+ - Without DELETE (including --keep-config), preserves config and CLI package.
263
+ - --keep-config and --confirm DELETE cannot be combined.
264
+ - --dry-run prints the selected services/config and exact CLI/npm/prefix plan.
265
+ - --stack all removes production and staging services, but only the selected
266
+ config directory and the launching CLI package.
241
267
  `);
242
268
  }
243
269
 
@@ -273,6 +299,31 @@ function runUninstall(args = []) {
273
299
  const configDir = dirname(configPath);
274
300
  const defaultBrowsePath = readConfiguredDefaultBrowsePath();
275
301
  const deleteConfig = options.confirm === "DELETE";
302
+ if (deleteConfig) {
303
+ try {
304
+ const plan = preflightCompleteUninstall({
305
+ cliPath: fileURLToPath(import.meta.url), configDir,
306
+ stack: options.stack || process.env.OYSTERUN_RELEASE_STACK || "production",
307
+ });
308
+ console.log("Oysterun complete uninstall plan:");
309
+ console.log(` Services: ${plan.services.map(t => `${t.name} (${t.label}, port ${t.port || "unconfigured"})`).join(", ")}`);
310
+ console.log(` Config directory: delete ${plan.config}`);
311
+ console.log(` CLI package: remove ${plan.root}`);
312
+ console.log(` npm: ${plan.identity.node_path} ${plan.identity.npm_cli_path} uninstall ${plan.npmArgs.join(" ")} oysterun`);
313
+ console.log(` Prefix: ${plan.prefix}; OS user: ${plan.user.name} (uid ${plan.user.uid})`);
314
+ console.log(" Default Browse Root and agent folders are never deleted by this command.");
315
+ if (options.dryRun) {
316
+ console.log("Dry run complete: no service, config or CLI changes were made.");
317
+ return;
318
+ }
319
+ process.exitCode = executeCompleteUninstall(plan).exitCode;
320
+ } catch (error) {
321
+ console.error(`oysterun uninstall preflight: ${error.message}`);
322
+ console.error("No service, config or CLI changes were made.");
323
+ process.exitCode = 1;
324
+ }
325
+ return;
326
+ }
276
327
  const serviceArgs = ["uninstall"];
277
328
  if (options.stack) {
278
329
  serviceArgs.push("--stack", options.stack);
@@ -290,7 +341,7 @@ function runUninstall(args = []) {
290
341
  defaultBrowsePath ? ` ${defaultBrowsePath}` : ""
291
342
  }`
292
343
  );
293
- console.log(" Global CLI package: preserve");
344
+ console.log(` Global CLI package: preserve ${repoRoot} (npm/prefix removal not requested)`);
294
345
  console.log("");
295
346
 
296
347
  if (!deleteConfig) {
@@ -301,9 +352,7 @@ function runUninstall(args = []) {
301
352
  console.log(
302
353
  "Default Browse Root and agent folders are never deleted by this command; remove them manually only if intended."
303
354
  );
304
- console.log(
305
- "To remove the global CLI after cleanup, run: npm uninstall -g oysterun"
306
- );
355
+ console.log("To remove service, config and this installed CLI together, run: oysterun uninstall --confirm DELETE");
307
356
 
308
357
  if (options.dryRun) {
309
358
  console.log("Dry run complete: no service or config changes were made.");
@@ -325,12 +374,7 @@ function runUninstall(args = []) {
325
374
  process.exit(result.status ?? 1);
326
375
  }
327
376
 
328
- if (deleteConfig) {
329
- rmSync(configDir, { recursive: true, force: true });
330
- console.log(`Deleted config directory: ${configDir}`);
331
- } else {
332
- console.log(`Preserved config directory: ${configDir}`);
333
- }
377
+ console.log(`Preserved config directory: ${configDir}`);
334
378
  }
335
379
 
336
380
  function printConfiguredStatus(status) {
@@ -382,6 +426,9 @@ switch (command) {
382
426
  case "setup":
383
427
  runSetup(rest);
384
428
  break;
429
+ case "onboard":
430
+ run(process.execPath, [onboardScript, ...rest], { setupInput: true });
431
+ break;
385
432
  case "show-qr":
386
433
  run(process.execPath, [setupScript, "--show-qr", ...rest]);
387
434
  break;
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Owns the lifetime of one completed-message navigation request at a time.
5
+ * Timeline movement is deliberately absent from this API: only click() reads.
6
+ *
7
+ * @template TResponse
8
+ * @param {{ readNeighbors: (request: { roomId: string, anchorEventId?: string, anchorPosition?: "latest" }) => Promise<TResponse>, selectTarget: (response: TResponse, direction: "previous" | "next") => string | undefined }} options
9
+ */
10
+ export function createCompletedMessageNeighborClickCoordinator(options) {
11
+ /** @type {string | null} */
12
+ let scope = null;
13
+ let generation = 0;
14
+ /** @type {{ generation: number, scope: string } | null} */
15
+ let active = null;
16
+ let disposed = false;
17
+
18
+ /** @param {string} nextScope */
19
+ function setScope(nextScope) {
20
+ if (scope === nextScope) return;
21
+ scope = nextScope;
22
+ generation += 1;
23
+ active = null;
24
+ }
25
+
26
+ return {
27
+ setScope,
28
+ dispose() {
29
+ disposed = true;
30
+ generation += 1;
31
+ active = null;
32
+ },
33
+ isLoading() {
34
+ return active !== null;
35
+ },
36
+ /**
37
+ * @param {{ scope: string, roomId: string, direction: "previous" | "next", anchorEventId?: string, anchorPosition?: "latest" }} request
38
+ */
39
+ async click(request) {
40
+ if (disposed || request.scope !== scope) return { status: "stale" };
41
+ if (active !== null) return { status: "busy" };
42
+
43
+ const transaction = { generation, scope: request.scope };
44
+ active = transaction;
45
+ try {
46
+ const response = await options.readNeighbors({
47
+ roomId: request.roomId,
48
+ anchorEventId: request.anchorEventId,
49
+ anchorPosition: request.anchorPosition,
50
+ });
51
+ if (
52
+ disposed ||
53
+ active !== transaction ||
54
+ generation !== transaction.generation ||
55
+ scope !== transaction.scope
56
+ ) {
57
+ return { status: "stale" };
58
+ }
59
+ const targetEventId = options.selectTarget(response, request.direction);
60
+ return targetEventId
61
+ ? { status: "target", response, targetEventId }
62
+ : { status: "no_target", response };
63
+ } catch (error) {
64
+ if (
65
+ disposed ||
66
+ active !== transaction ||
67
+ generation !== transaction.generation ||
68
+ scope !== transaction.scope
69
+ ) {
70
+ return { status: "stale" };
71
+ }
72
+ return { status: "error", error };
73
+ } finally {
74
+ if (active === transaction) active = null;
75
+ }
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,205 @@
1
+ // @ts-check
2
+
3
+ export const COMPLETED_MESSAGE_NEIGHBOR_SCHEMA_VERSION =
4
+ "routec.p206_completed_turn_final_message_neighbors.v1";
5
+
6
+ /** @typedef {{ event_id: string, routec_stream_seq: number, origin_server_ts: number | null, provider_turn_id: string }} CompletedMessageNeighborEvent */
7
+ /** @typedef {{ anchor_position: "event", event_id: string, routec_stream_seq: number } | { anchor_position: "latest", event_id: null, routec_stream_seq: number | null }} CompletedMessageNeighborAnchor */
8
+ /** @typedef {{ schema_version: typeof COMPLETED_MESSAGE_NEIGHBOR_SCHEMA_VERSION, lookup_source: string, lookup_strategy: string, order_by: "final_candidate_stream_seq", candidate_predicate: "complete_message_notification_candidate", completion_predicate: "complete_message_notification_completion", navigation_index_version: number | null, navigation_index_names: string[], raw_event_scan_limit: null, raw_event_payload_returned: false, message_body_returned: false }} CompletedMessageNeighborProof */
9
+ /** @typedef {{ status: "ok", session_id: string, matrix_room_id: string, anchor: CompletedMessageNeighborAnchor, previous: CompletedMessageNeighborEvent | null, next: CompletedMessageNeighborEvent | null, boundaries: { no_completed_turns: boolean, at_first_completed_message: boolean, at_latest_completed_message: boolean }, proof: CompletedMessageNeighborProof, committed_transcript_truth?: "matrix_room_timeline", routec_completed_message_neighbor_endpoint?: true, completed_message_truth_source?: "complete_message_notification_candidate_and_completion_predicates", raw_event_payload_returned: false, message_body_returned: false }} CompletedMessageNeighborsResponse */
10
+ /** @typedef {{ sessionId?: string, matrixRoomId?: string, anchorEventId?: string | null, anchorPosition?: "latest" | null }} CompletedMessageNeighborExpectedIdentity */
11
+
12
+ /**
13
+ * @param {unknown} value
14
+ * @returns {value is Record<string, unknown>}
15
+ */
16
+ function isRecord(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {value is string}
23
+ */
24
+ function nonEmptyString(value) {
25
+ return typeof value === "string" && value.trim().length > 0;
26
+ }
27
+
28
+ /**
29
+ * @param {unknown} value
30
+ * @returns {value is number}
31
+ */
32
+ function isNonnegativeSafeInteger(value) {
33
+ return Number.isSafeInteger(value) && Number(value) >= 0;
34
+ }
35
+
36
+ /**
37
+ * @param {unknown} value
38
+ * @returns {value is CompletedMessageNeighborEvent}
39
+ */
40
+ function isNeighbor(value) {
41
+ return (
42
+ isRecord(value) &&
43
+ nonEmptyString(value.event_id) &&
44
+ isNonnegativeSafeInteger(value.routec_stream_seq) &&
45
+ (typeof value.origin_server_ts === "number" ||
46
+ value.origin_server_ts === null) &&
47
+ nonEmptyString(value.provider_turn_id)
48
+ );
49
+ }
50
+
51
+ /**
52
+ * @param {CompletedMessageNeighborsResponse} value
53
+ * @param {CompletedMessageNeighborExpectedIdentity} expected
54
+ */
55
+ function assertRequestIdentity(value, expected) {
56
+ if (expected.sessionId && value.session_id !== expected.sessionId) {
57
+ throw new Error("Completed-message neighbor Session identity mismatch.");
58
+ }
59
+ if (expected.matrixRoomId && value.matrix_room_id !== expected.matrixRoomId) {
60
+ throw new Error("Completed-message neighbor room identity mismatch.");
61
+ }
62
+ if (expected.anchorEventId) {
63
+ if (
64
+ value.anchor.anchor_position !== "event" ||
65
+ value.anchor.event_id !== expected.anchorEventId
66
+ ) {
67
+ throw new Error("Completed-message neighbor anchor identity mismatch.");
68
+ }
69
+ } else if (expected.anchorPosition === "latest") {
70
+ if (
71
+ value.anchor.anchor_position !== "latest" ||
72
+ value.anchor.event_id !== null
73
+ ) {
74
+ throw new Error("Completed-message neighbor latest anchor mismatch.");
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {unknown} value
81
+ * @param {CompletedMessageNeighborExpectedIdentity} [expected]
82
+ * @returns {CompletedMessageNeighborsResponse}
83
+ */
84
+ export function assertCompletedMessageNeighborsResponse(value, expected = {}) {
85
+ if (!isRecord(value)) {
86
+ throw new Error("Completed-message neighbor response must be an object.");
87
+ }
88
+ const anchor = value.anchor;
89
+ const boundaries = value.boundaries;
90
+ const proof = value.proof;
91
+ const validAnchor =
92
+ isRecord(anchor) &&
93
+ (anchor.anchor_position === "event" ||
94
+ anchor.anchor_position === "latest") &&
95
+ (typeof anchor.event_id === "string" || anchor.event_id === null) &&
96
+ (isNonnegativeSafeInteger(anchor.routec_stream_seq) ||
97
+ anchor.routec_stream_seq === null) &&
98
+ (anchor.anchor_position === "event"
99
+ ? nonEmptyString(anchor.event_id) &&
100
+ isNonnegativeSafeInteger(anchor.routec_stream_seq)
101
+ : anchor.event_id === null);
102
+ const indexNames = isRecord(proof) ? proof.navigation_index_names : null;
103
+ const indexVersion = isRecord(proof)
104
+ ? proof.navigation_index_version
105
+ : undefined;
106
+ const validIndexDiagnostics =
107
+ Array.isArray(indexNames) &&
108
+ indexNames.every(nonEmptyString) &&
109
+ new Set(indexNames).size === indexNames.length &&
110
+ (Number.isSafeInteger(indexVersion) || indexVersion === null) &&
111
+ ((indexVersion === null && indexNames.length === 0) ||
112
+ (Number.isSafeInteger(indexVersion) && indexNames.length > 0));
113
+ const validProof =
114
+ isRecord(proof) &&
115
+ proof.schema_version === COMPLETED_MESSAGE_NEIGHBOR_SCHEMA_VERSION &&
116
+ nonEmptyString(proof.lookup_source) &&
117
+ nonEmptyString(proof.lookup_strategy) &&
118
+ proof.order_by === "final_candidate_stream_seq" &&
119
+ proof.candidate_predicate === "complete_message_notification_candidate" &&
120
+ proof.completion_predicate === "complete_message_notification_completion" &&
121
+ validIndexDiagnostics &&
122
+ proof.raw_event_scan_limit === null &&
123
+ proof.raw_event_payload_returned === false &&
124
+ proof.message_body_returned === false;
125
+ if (
126
+ value.status !== "ok" ||
127
+ !nonEmptyString(value.session_id) ||
128
+ !nonEmptyString(value.matrix_room_id) ||
129
+ !validAnchor ||
130
+ !(value.previous === null || isNeighbor(value.previous)) ||
131
+ !(value.next === null || isNeighbor(value.next)) ||
132
+ !isRecord(boundaries) ||
133
+ typeof boundaries.no_completed_turns !== "boolean" ||
134
+ typeof boundaries.at_first_completed_message !== "boolean" ||
135
+ typeof boundaries.at_latest_completed_message !== "boolean" ||
136
+ !validProof ||
137
+ !(
138
+ value.committed_transcript_truth === undefined ||
139
+ value.committed_transcript_truth === "matrix_room_timeline"
140
+ ) ||
141
+ !(
142
+ value.routec_completed_message_neighbor_endpoint === undefined ||
143
+ value.routec_completed_message_neighbor_endpoint === true
144
+ ) ||
145
+ !(
146
+ value.completed_message_truth_source === undefined ||
147
+ value.completed_message_truth_source ===
148
+ "complete_message_notification_candidate_and_completion_predicates"
149
+ ) ||
150
+ value.raw_event_payload_returned !== false ||
151
+ value.message_body_returned !== false
152
+ ) {
153
+ throw new Error("Completed-message neighbor response failed validation.");
154
+ }
155
+ const validated = /** @type {CompletedMessageNeighborsResponse} */ (value);
156
+ if (
157
+ validated.boundaries.at_first_completed_message !==
158
+ (validated.previous === null) ||
159
+ validated.boundaries.at_latest_completed_message !==
160
+ (validated.next === null) ||
161
+ (boundaries.no_completed_turns &&
162
+ (validated.previous !== null ||
163
+ validated.next !== null ||
164
+ !boundaries.at_first_completed_message ||
165
+ !boundaries.at_latest_completed_message))
166
+ ) {
167
+ throw new Error("Completed-message neighbor boundaries are inconsistent.");
168
+ }
169
+ if (validated.anchor.anchor_position === "event") {
170
+ if (
171
+ (validated.previous &&
172
+ validated.previous.routec_stream_seq >=
173
+ validated.anchor.routec_stream_seq) ||
174
+ (validated.next &&
175
+ validated.next.routec_stream_seq <= validated.anchor.routec_stream_seq)
176
+ ) {
177
+ throw new Error("Completed-message neighbor ordering is inconsistent.");
178
+ }
179
+ }
180
+ assertRequestIdentity(validated, expected);
181
+ return validated;
182
+ }
183
+
184
+ /**
185
+ * @param {Omit<CompletedMessageNeighborsResponse, "status" | "raw_event_payload_returned" | "message_body_returned">} input
186
+ * @returns {CompletedMessageNeighborsResponse}
187
+ */
188
+ export function buildCompletedMessageNeighborsResponse(input) {
189
+ return assertCompletedMessageNeighborsResponse(
190
+ {
191
+ status: "ok",
192
+ ...input,
193
+ raw_event_payload_returned: false,
194
+ message_body_returned: false,
195
+ },
196
+ {
197
+ sessionId: input.session_id,
198
+ matrixRoomId: input.matrix_room_id,
199
+ anchorEventId:
200
+ input.anchor.anchor_position === "event" ? input.anchor.event_id : null,
201
+ anchorPosition:
202
+ input.anchor.anchor_position === "latest" ? "latest" : null,
203
+ }
204
+ );
205
+ }
@@ -19,12 +19,12 @@ export function evaluateCompletedMessageVisibilityAcknowledgement(input: {
19
19
  currentMatrixRoomId?: string;
20
20
  targetMatrixRoomId?: string;
21
21
  latestCompletedEventId?: string | null;
22
- renderedEventId?: string | null;
22
+ latestTimelineLoaded?: boolean;
23
23
  hasUnreadCompletedMessage?: boolean;
24
24
  documentVisibilityState?: string;
25
25
  documentHasFocus?: boolean;
26
- elementConnected?: boolean;
27
- elementHidden?: boolean;
28
- elementRect?: VisibilityRect | DOMRect;
26
+ visibleMessageConnected?: boolean;
27
+ visibleMessageHidden?: boolean;
28
+ visibleMessageRect?: VisibilityRect | DOMRect;
29
29
  viewportRect?: VisibilityRect | DOMRect;
30
30
  }): { qualified: boolean; reason: string };
@@ -30,13 +30,13 @@ export function evaluateCompletedMessageVisibilityAcknowledgement({
30
30
  currentMatrixRoomId,
31
31
  targetMatrixRoomId,
32
32
  latestCompletedEventId,
33
- renderedEventId,
33
+ latestTimelineLoaded,
34
34
  hasUnreadCompletedMessage,
35
35
  documentVisibilityState,
36
36
  documentHasFocus,
37
- elementConnected,
38
- elementHidden,
39
- elementRect,
37
+ visibleMessageConnected,
38
+ visibleMessageHidden,
39
+ visibleMessageRect,
40
40
  viewportRect,
41
41
  }) {
42
42
  if (routeCChatShell !== true)
@@ -49,19 +49,22 @@ export function evaluateCompletedMessageVisibilityAcknowledgement({
49
49
  }
50
50
  if (hasUnreadCompletedMessage !== true)
51
51
  return { qualified: false, reason: "not_unread" };
52
- if (!latestCompletedEventId || latestCompletedEventId !== renderedEventId) {
53
- return { qualified: false, reason: "stale_or_unrendered_event" };
52
+ if (!latestCompletedEventId || latestTimelineLoaded !== true) {
53
+ return { qualified: false, reason: "latest_timeline_not_ready" };
54
54
  }
55
55
  if (documentVisibilityState !== "visible") {
56
56
  return { qualified: false, reason: "hidden_document" };
57
57
  }
58
58
  if (documentHasFocus !== true)
59
59
  return { qualified: false, reason: "unfocused_document" };
60
- if (elementConnected !== true || elementHidden === true) {
61
- return { qualified: false, reason: "event_not_materially_rendered" };
60
+ if (visibleMessageConnected !== true || visibleMessageHidden === true) {
61
+ return { qualified: false, reason: "no_visible_materialized_message" };
62
62
  }
63
- if (!isCompletedMessageViewportVisible({ elementRect, viewportRect })) {
64
- return { qualified: false, reason: "event_outside_viewport" };
63
+ if (!isCompletedMessageViewportVisible({
64
+ elementRect: visibleMessageRect,
65
+ viewportRect,
66
+ })) {
67
+ return { qualified: false, reason: "no_visible_materialized_message" };
65
68
  }
66
- return { qualified: true, reason: "visible_latest_successful_final" };
69
+ return { qualified: true, reason: "visible_focused_latest_timeline" };
67
70
  }
@@ -0,0 +1,57 @@
1
+ export const DESKTOP_CHAT_SIDEBAR_PREFERENCE_KEY =
2
+ "oysterun.desktop_chat_sidebar_expanded.v1";
3
+
4
+ const readStorage = (windowTarget) => {
5
+ try {
6
+ return windowTarget?.localStorage;
7
+ } catch {
8
+ return undefined;
9
+ }
10
+ };
11
+
12
+ export function readDesktopChatSidebarPreference(windowTarget = globalThis) {
13
+ try {
14
+ const stored = readStorage(windowTarget)?.getItem(
15
+ DESKTOP_CHAT_SIDEBAR_PREFERENCE_KEY
16
+ );
17
+ if (stored === "false") return false;
18
+ if (stored === "true") return true;
19
+ } catch {
20
+ // Browser privacy modes may expose localStorage while rejecting reads.
21
+ }
22
+ return true;
23
+ }
24
+
25
+ export function writeDesktopChatSidebarPreference(
26
+ expanded,
27
+ windowTarget = globalThis
28
+ ) {
29
+ try {
30
+ const storage = readStorage(windowTarget);
31
+ if (typeof storage?.setItem !== "function") return false;
32
+ storage.setItem(
33
+ DESKTOP_CHAT_SIDEBAR_PREFERENCE_KEY,
34
+ expanded === true ? "true" : "false"
35
+ );
36
+ return true;
37
+ } catch {
38
+ // Preference persistence must never make Chat unavailable.
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export function subscribeDesktopChatSidebarPreference(
44
+ listener,
45
+ windowTarget = globalThis
46
+ ) {
47
+ if (typeof listener !== "function") {
48
+ throw new TypeError("Desktop Chat sidebar preference listener is required");
49
+ }
50
+ if (typeof windowTarget?.addEventListener !== "function") return () => {};
51
+ const onStorage = (event) => {
52
+ if (event?.key !== DESKTOP_CHAT_SIDEBAR_PREFERENCE_KEY) return;
53
+ listener(readDesktopChatSidebarPreference(windowTarget));
54
+ };
55
+ windowTarget.addEventListener("storage", onStorage);
56
+ return () => windowTarget.removeEventListener("storage", onStorage);
57
+ }
@@ -1,4 +1,4 @@
1
- export const HOST_CONNECTION_RECONNECT_WARNING_DELAY_MS = 1_500;
1
+ export const HOST_CONNECTION_RECONNECT_WARNING_DELAY_MS = 5_000;
2
2
  export const HOST_CONNECTION_LOST_DELAY_MS = 30_000;
3
3
  export const PAGE_REFRESH_PENDING_RESET_MS = 1_500;
4
4
 
@@ -94,8 +94,11 @@ export function createHostConnectionFeedbackController({
94
94
  let warningTimer = null;
95
95
  let lostTimer = null;
96
96
  let lastPresentationKey = "";
97
+ let timerGeneration = 0;
98
+ let closed = false;
97
99
 
98
100
  const emit = () => {
101
+ if (closed) return;
99
102
  const presentation = connectionFeedbackPresentation({
100
103
  signal,
101
104
  disconnectedAt,
@@ -108,6 +111,7 @@ export function createHostConnectionFeedbackController({
108
111
  };
109
112
 
110
113
  const clearTimers = () => {
114
+ timerGeneration += 1;
111
115
  if (warningTimer !== null) clearTimeoutImpl(warningTimer);
112
116
  if (lostTimer !== null) clearTimeoutImpl(lostTimer);
113
117
  warningTimer = null;
@@ -115,11 +119,16 @@ export function createHostConnectionFeedbackController({
115
119
  };
116
120
 
117
121
  const scheduleThresholds = () => {
122
+ const generation = timerGeneration;
118
123
  warningTimer = setTimeoutImpl(() => {
124
+ // A queued callback must not publish after recovery/unmount or erase a
125
+ // newer recovery's timer handle.
126
+ if (closed || generation !== timerGeneration) return;
119
127
  warningTimer = null;
120
128
  emit();
121
129
  }, HOST_CONNECTION_RECONNECT_WARNING_DELAY_MS);
122
130
  lostTimer = setTimeoutImpl(() => {
131
+ if (closed || generation !== timerGeneration) return;
123
132
  lostTimer = null;
124
133
  emit();
125
134
  }, HOST_CONNECTION_LOST_DELAY_MS);
@@ -127,6 +136,7 @@ export function createHostConnectionFeedbackController({
127
136
 
128
137
  return {
129
138
  handleStatus(status) {
139
+ if (closed) return;
130
140
  const nextSignal = normalizeHostConnectionStatus(status);
131
141
  const wasConnected = signal?.connected !== false;
132
142
  signal = nextSignal;
@@ -147,6 +157,7 @@ export function createHostConnectionFeedbackController({
147
157
  emit();
148
158
  },
149
159
  close() {
160
+ closed = true;
150
161
  clearTimers();
151
162
  },
152
163
  };
@@ -2671,6 +2671,16 @@ export function startManagedHostConnection() {
2671
2671
  return getManagedHostBrowserConnection().start();
2672
2672
  }
2673
2673
 
2674
+ // A WebRTC connection is authenticated with the dashboard token used to open
2675
+ // it. Keep the Host's connection-bound authorization policy: a new login must
2676
+ // retire that connection rather than send a new bearer through the old peer.
2677
+ export async function refreshManagedHostAuthentication() {
2678
+ const connection = getManagedHostBrowserConnection();
2679
+ if (connection instanceof ManagedHostBrowserConnection) {
2680
+ await connection.restartWebRtc("dashboard_login", { initiator: "browser" });
2681
+ }
2682
+ }
2683
+
2674
2684
  export function managedHostFetch(input, init) {
2675
2685
  return getManagedHostBrowserConnection().fetch(input, init);
2676
2686
  }